rfc:optional_php_tags

PHP RFC: Pure-code source files via the ''.phpc'' extension

Changelog

  • 0.4 (2026-06-16) – Discussion-phase revision. Added two new “What this RFC does NOT claim” entries acknowledging Davey Shafik's points: (a) .phpc is a categorical precedent shift (first PHP feature where filename extension drives lexer state at file-open time), and (b) .phpc files lose the portable in-code “this is PHP” signal that <?php provides. Both costs are real, even if small. The RFC text was previously slightly over-leaning on “strictly additive” and “no downside” framings.
  • 0.3 (2026-06-16) – Discussion-phase revision. Rewrote the Motivation section to one core sentence plus a “What this RFC does NOT claim” subsection (per Kamil Tekiela's feedback that the original four-bullet motivation was diffuse). Added a new Forward Compatibility section addressing the silent-text-emission risk when .phpc files are loaded by PHP versions that pre-date the RFC, including the Composer-mediated install-time mitigation (per Alex Rock's concern). Added a Rejected Alternatives entry on bundling this RFC with a hypothetical JS-style strict mode (per Michael Morris's “one-shot opportunity” framing). Inline DokuWiki-escape fixes for __halt_compiler, __COMPILER_HALT_OFFSET__, and phar:// so the wiki rendering no longer eats the underscores or italic-parses the double slash. The Examples section was simplified to avoid magic identifiers (__construct, __DIR__, __FILE__) that are eaten by some DokuWiki syntax-highlighter plugins inside <code> blocks; the test suite at Zend/tests/phpc/ continues to cover classes, magic constants, and the halt-compiler offset.
  • 0.2 (2026-06-15) – Initial publication. Pivoted from auto-detect on .php (considered during pre-RFC drafting) to opt-in via .phpc extension, per pre-RFC consensus.
  • 0.1 (pre-RFC draft) – Internal draft, not published. Auto-detect on first byte of .php files. Rejected after pre-RFC discussion exposed BC concerns.

Introduction

PHP was born as a templating language. Every .php file is parsed in “inline content” mode and only enters scripting mode upon encountering an opening tag (<?php, <?=, or the long-deprecated <?). For files that ARE primarily PHP code – libraries, application classes, autoloader targets, CLI tools, framework internals – this is pure ceremony that hard-codes PHP's templating heritage into every file the language touches.

This RFC introduces a single, opt-in file extension – .phpc (“PHP code”) – whose semantics are: the file is pure PHP. The lexer enters ST_IN_SCRIPTING on the first byte of the file. The classic .php extension is completely unchanged: zero BC, zero behavior shift, zero risk to the templating use case PHP has always supported.

This was the approach the author proposed in the pre-RFC discussion (Mennen, 2026-05-27). The thread elicited substantive technical feedback – on SAPI vs. engine dispatch (Ben Ramsey), on alternative mechanisms (Alex Rock), on backward compatibility (Bruce Weirdan), and on security – which this document explicitly addresses below.

Motivation

The single core motivation:

Most new PHP files written today are pure code – framework classes, autoloaded library code, console commands, configuration files, route definitions. For these files the ''<?php'' opener carries no semantic information; it is a vestige of PHP's templating heritage that every file must repeat. PHP is the only modern mainstream language in which this prefix is mandatory.

.phpc is the smallest possible mechanism to remove that vestige for the files where it carries no meaning, without touching anything else.

What this RFC does NOT claim

To be explicit about what is NOT being argued:

  • Not “the current model is broken”. PHP-as-templating works and will continue to work. .php files are unchanged.
  • Not “this is a big ergonomic win”. It is a small one. A reader who finds it negligible is not wrong.
  • Not “additive features are automatically welcome”. “Strictly additive” is a necessary property of this RFC (anything that broke .php would be DOA), not a motivation for accepting it.
  • Not “this is a free change with no downside”. It is a categorical shift: this would be the first PHP feature in which the filename's extension drives lexer state at file-open time. .phar has some filename-aware engine semantics via the phar:// stream wrapper, but no precedent makes the lexer's starting state depend on the extension. Accepting this RFC means accepting that precedent. (Acknowledged in response to Davey Shafik, Discussion thread.)
  • Not “the in-code 'this is PHP' signal is irrelevant”. The <?php opener is a portable marker that travels with a code excerpt into Stack Overflow answers, blog posts, diff viewers, and IDEs that hide file extensions. A bare .phpc snippet loses that signal until the reader knows what .phpc means. The cost is small (most snippets already drop <?php for brevity, context usually disambiguates) but it is not zero. (Acknowledged in response to Davey Shafik.)

Supporting considerations (not strong enough alone, but cumulative)

  • CLI scripts. Today a CLI script reads #!/usr/bin/env php then <?php on the next line – a small hybrid header that no other #!-language requires. With .phpc, a script reads like Python, Ruby, or Node.
  • Engine-enforced file-mode signal. .phtml is a community convention for templates with no engine semantics. .phpc would carry an engine guarantee: a .phpc file IS pure code – a property tools (IDEs, search, static analysis, code review) can rely on, not just assume.
  • Aesthetic alignment with the modern code-only majority of PHP. PHP looked different from other languages in the 1990s for good reasons. The templating shape no longer represents the median PHP file written in 2026.

Proposal

Detection rule

In Zend/zend_language_scanner.l::open_file_for_scanning, after the file buffer is loaded, the filename is checked. If it ends in the byte sequence .phpc (byte-exact memcmp, case-sensitive on all platforms in the reference implementation – see Open Issues for the case-folding discussion), the file is treated as pure-PHP:

  1. Skip a leading UTF-8 BOM (0xEF 0xBB 0xBF), if present.
  2. If CLI shebang skipping is enabled and the next two bytes are #!, skip the entire shebang line up to and including the trailing \n and set the starting line to 2.
  3. Enter ST_IN_SCRIPTING. The lexer is now positioned to consume the first byte of code as PHP.

If the filename does not end in .phpc, the existing flow runs unchanged: BEGIN(SHEBANG) if CLI shebang skipping is active, otherwise BEGIN(INITIAL).

What is unchanged

Inside a .phpc file, the rest of the lexer is the same lexer that processes a .php file in ST_IN_SCRIPTING. Specifically:

  • ?> continues to drop to inline output. A .phpc file CAN trail with non-PHP content (rare but legal).
  • __halt_compiler(); continues to work and __COMPILER_HALT_OFFSET__ is populated as today. This is verified in the test suite (Zend/tests/phpc/006_halt_compiler.phpt).
  • A literal <?php inside a .phpc file is no longer a “tag” – it is parsed as code, which is a syntax error. Authors who accidentally double-open get a clear error, not silent text emission.
  • <?= has no special meaning inside .phpc.

Examples

A .phpc library file (src/Greeter.phpc):

namespace App\Demo;
 
function greet(string $who): string {
    return "hello, $who";
}

A CLI shebang script (bin/migrate.phpc):

#!/usr/bin/env php
declare(strict_types=1);
 
[$bin, $command] = $argv + [null, '--help'];
echo "running: $command\n";

A classic app/bootstrap.php that requires a .phpc library:

<?php
require 'src/greet.phpc';
echo App\Demo\greet('world');

(The examples above deliberately avoid magic identifiers like __construct and __DIR__ for clean rendering on wikis whose plugins process wiki markup inside <code> blocks. Full class definition and __halt_compiler() coverage is in the test suite at Zend/tests/phpc/ – tests 010 and 006 respectively.)

Why engine-level dispatch (response to Ben Ramsey)

Ben Ramsey raised the principled objection that “file extension cannot have meaning for the engine” and proposed SAPI-level handler configuration (web server mappings) plus a CLI -p flag for stdin as alternatives (php.internals/131026).

The author respectfully disagrees that SAPI configuration is sufficient, on technical grounds:

  1. require / require_once / include / include_once all execute inside the engine, via compile_file()open_file_for_scanning(). They do not consult the SAPI handler map. A web-server-only mapping would route the entry point but not the autoloaded .phpc library files it pulls in.
  2. Phar archives likewise resolve internal entries through the engine. A Phar containing a mix of .php templates and .phpc library files needs both formats to dispatch correctly without per-Phar configuration.
  3. URL-stream includes (include 'phar://app.phar/foo.phpc') are similarly engine-resolved.
  4. The CLI -p flag for stdin-as-pure-PHP is a genuinely good idea and is included below as a complementary feature, not a replacement.

Engine-level dispatch is the only mechanism that uniformly handles every code-loading path PHP supports. The extension check itself is a single memcmp on the filename's tail plus a few buffer-position adjustments – roughly 50 lines of straight-line C in open_file_for_scanning (a function that already runs once per compiled file). The runtime overhead is far below the cost of the surrounding I/O and lexer work; no benchmark numbers are claimed for it, on the basis that “constant-time tail compare on a string PHP has already loaded” needs no benchmarking.

Why not an environment variable / include_pure keyword / declare directive (response to Alex Rock)

Alex Rock suggested three alternatives in the pre-RFC thread (php.internals/131032):

  • PHP_INPUT_PURE=0|1 environment variable, checked only for the first included file.
  • A new include_pure / require_pure (plus _once) family of keywords.
  • A boolean argument to existing include/require.

These are addressed in the Rejected Alternatives section below. The short version: each one shifts the decision about a file's parsing mode away from the file itself onto every caller of the file. .phpc keeps that decision where it belongs – with the file's author, encoded in its name, visible to every reader and tool, decoupled from how the file is loaded.

Backward Compatibility

The change is purely additive in the strictest sense: every byte sequence that compiled before this RFC compiles to the exact same op_array after this RFC, as long as the file extension is not .phpc. The full php-src test suite (Zend/, ext/tokenizer/, ext/standard/, ext/spl/, ext/reflection/, ext/phar/ – 9836 tests) passes with the reference implementation applied, with zero modifications to any pre-existing test.

The only theoretical BC concern, as Bruce Weirdan noted (php.internals/131038), is that “people could already be using files with that extension”. A .phpc file that contained template-style content (e.g., a file someone happens to have named page.phpc but that consists of HTML with embedded <?php blocks) would be reinterpreted as pure PHP.

Mitigating context:

  • .phpc is not a registered MIME type, not a documented PHP extension, not part of the PHP manual's accepted file-extension list, and not present in the default SAPI handler maps shipped by Apache (mod_php), nginx (sample fastcgi configs), Caddy, or FrankenPHP.
  • Composer's autoloader by default discovers .php files only; no major framework (Symfony, Laravel, CakePHP, CodeIgniter, Yii) lists .phpc as a recognised extension.

A small but real population of files using .phpc for unrelated purposes (custom build tooling, hand-rolled cache files, ad-hoc dev conventions) is to be expected on a language as old as PHP. The author has not conducted a comprehensive scan of public code corpora; an honest assessment is that the collision risk is small but not zero, comparable to any new file convention. The author requests that internals voters who have visibility into private codebases weigh in on this during the Discussion phase – an Open Issue below asks for community data points before the vote.

Forward Compatibility

Raised by Alex Rock in the Discussion thread: when a .phpc file is loaded by a PHP version that does not implement this RFC, the lexer enters its existing INITIAL state and emits the file contents as inline output. This is a worse failure mode than a plain syntax error, because in a web context it can silently expose source code.

This is real and worth flagging explicitly. The mitigations:

  1. Composer-mediated install-time enforcement (primary). A library shipping .phpc files declares the target version in its composer.json:
{ "require": { "php": ">=8.6" } }
  ''composer install'' fails with a clear ''php'' version error on lower versions. The ''.phpc'' file never reaches an interpreter that does not understand it. This is the same mechanism every 8.x feature relies on (readonly classes, enums, property hooks); ''.phpc'' adds no new constraint.
- **Application code is essentially unaffected.** Applications own the PHP version they deploy on; mismatches are a deployment error, not a language one.
- **Shared libraries should adopt cautiously.** Library authors who want to support older PHP versions should NOT ship ''.phpc'' files yet, the same constraint that delayed widespread adoption of readonly classes, enums, etc.
- **Non-Composer distribution** (manual ''git clone'', raw ''.zip'', vendored copies) bypasses install-time enforcement. The user assumes responsibility for matching PHP versions, identical to the situation for any 8.x feature.

The author has considered, and rejected, alternative mechanisms intended to make .phpc on an older interpreter fail hard rather than silently:

  • A required canary header (e.g. mandatory <?php die(); as the first line). Rejected: defeats the entire point of .phpc (lexer starts in ST_IN_SCRIPTING; <?php inside .phpc is a syntax error per the RFC's own rules).
  • A magic byte sequence at file start. Rejected: ceremony, complicates tooling.
  • A polyfill / compatibility shim for older PHP. Rejected: engine semantics are not poly-fillable in userland.

The honest position is that forward-compatibility for .phpc-using libraries is bounded by Composer's existing PHP-version enforcement and by responsible library-author discipline. Both mechanisms are in active use today.

Security

This RFC ships with security as a primary concern – not an afterthought – per the author's commitment in the pre-RFC discussion (php.internals/131035).

The risk

The risk is structurally identical to the historical .inc problem: a misconfigured web server serves a .phpc file as raw text, exposing PHP source code (and any secrets it contains) to anyone who requests the URL.

Required mitigations

This RFC ships with the following accompanying commitments:

  1. Documentation. The PHP manual entry for the feature must include canonical, copy-pasteable server configurations for Apache (.htaccess + mod_php), Nginx (FastCGI), Caddy, and FrankenPHP that either route .phpc to the PHP handler or deny it. Reference snippets are included as Appendix A of this RFC. The author commits to authoring the manual section as part of landing.
  2. Distro/packager outreach. The author commits to opening tracking issues with the major distro PHP packagers (Debian/Ubuntu, RHEL/Fedora, Homebrew, Laravel Herd, Docker Official php images) to surface .phpc alongside .php in their default configurations.
  3. Composer coordination. A pre-landing PR or issue against composer/composer to confirm that PSR-4 autoloading dispatches .phpc alongside .php. This is strongly desired but not a hard prerequisite for the language change to land – Composer can adopt asynchronously.

.phpc files SHOULD live outside any web-served document root. The natural conventions follow what PSR-4-loaded code already does: src/, lib/, app/ – none of which are typically web-exposed. PHP CLI tools placed in bin/ are also outside document roots. The author recommends that frameworks (Symfony, Laravel, etc.) adopt .phpc as the default extension for newly-generated classes once stable, while leaving .php for explicit template files and public/ entry points.

RFC Impact

SAPIs

No code changes in any SAPI. The CLI shebang-skipping path (CG(skip_shebang)) is reused; the .phpc extension check is in the shared file-open path.

Existing Extensions

  • ext/tokenizer. token_get_all() / PhpToken::tokenize() operate on strings, not files. They are unaffected; their output for <?php echo 1; is identical before and after this RFC, and is asserted in Zend/tests/phpc/012_token_get_all_unchanged.phpt. Direct file tokenization via token_get_all(file_get_contents(“foo.phpc”)) still requires the caller to prepend <?php — a userland helper or a follow-up tokenizer flag (see Future Scope) would address that ergonomically, but is deliberately out of this RFC's scope.
  • ext/phar. Phar archives invoke the same compile_file() code path with a phar://archive.phar/entry filename. The .phpc extension check is byte-exact on the filename's tail, so a Phar entry named lib.phpc is dispatched as pure-PHP exactly like a filesystem .phpc file. This is the design intent; the reference implementation does not yet ship a dedicated Phar test (Phar test fixtures are heavyweight), but the code path is shared and a Phar test will be added during the Discussion phase if reviewers request it.
  • ext/opcache. OPcache caches post-compile op_arrays. They are identical to those produced by the classic path. Zero code change.
  • ext/reflection. No surface changes.

Internal helpers

  • eval() uses ZEND_COMPILE_POSITION_AFTER_OPEN_TAG (the string-compile path) and is unaffected: an eval'd snippet is already implicit-scripting, regardless of file extension.
  • highlight_file() / show_source() tokenize files via the file-compile path. For .phpc files the output is expected to omit the leading T_OPEN_TAG token, mirroring .phpc's actual token stream. Behaviour is by design, not code change; will be covered by a Discussion-phase test if reviewers request it.
  • __halt_compiler() continues to work; the offset constant is populated from the lexer's position, which is unchanged.

php.ini Defaults

No new INI directives. (Notably, no global pure_php_files=on toggle – see Rejected Alternatives.)

Alternative extension names

The choice of .phpc is open for community feedback. Pre-RFC respondents flagged two friction points (php.internals/131027): a visual collision with Python's .pyc (bytecode files), and some scattered prior use of .phpc for unrelated tooling. The author considers .phpc the strongest semantic fit (“c” for “code”) but has committed to presenting alternatives in this section for voting:

  • .phpc – first choice, “code”
  • .phpp – “pure” (note: this was the extension Boutell proposed in his 2012 RFC, which failed for unrelated reasons)
  • .pcode – descriptive but verbose
  • .phpx – “executable”

The mechanism (extension-based engine-level dispatch) is independent of which letters the extension contains; this RFC's voting allows a separate sub-vote for the name.

Why this is different from prior failed RFCs

  • Boutell 2012, “Source Files without Opening Tag” (https://wiki.php.net/rfc/source_files_without_opening_tag) – introduced a new AS keyword and required a per-include marker. Rejected primarily for syntactic overhead. This RFC: zero new syntax, zero per-include markers; dispatch is purely by filename.
  • Ohgaki 2014, “No-PHP-tags Mode” (https://wiki.php.net/rfc/nophptags) – proposed a php.ini toggle that flipped the mode for all files in the installation. Rejected because it produced two incompatible PHP dialects per server and broke template-engine cache files. This RFC: no global toggle; .phpc and .php coexist file-by-file.

Impact on template engines (Twig, Blade, Latte, Smarty, …)

None. All mainstream PHP template engines emit .php cache files prefixed with <?php as the first bytes. .phpc is opt-in by filename; no template engine, by default, writes .phpc files. Engines that wish to adopt .phpc for their compiled caches in the future may do so transparently; the engine code that invokes the cache is unaffected.

Open Issues

  1. Extension name (see above). Vote separately.
  2. Real-world .phpc usage data. Author has not scanned public corpora; would welcome data points from internals voters with visibility into private/internal codebases on whether .phpc is in non-trivial use as a non-PHP file extension.
  3. Case-sensitivity of the extension check. The reference implementation does a byte-exact memcmp for .phpc, so Foo.PHPC on a case-insensitive filesystem (Windows NTFS, default macOS APFS) would NOT be recognised. The alternative – zend_binary_strcasecmp for the extension – would be more forgiving but introduces locale concerns. Strict byte-exact is the conservative default; this is open for discussion.
  4. Composer coordination. Confirm with Composer maintainers that PSR-4 autoloader will dispatch .phpc alongside .php with no user-side config required. (Strongly desired, not a hard prerequisite for the language change itself.)
  5. OPcache file-key format. Verify that opcache's file cache key (path-based hash) treats .phpc as distinct from .php (it does – the key is a path string – but worth confirming in code review).

Future Scope

  • CLI -p / --pure flag. Ben Ramsey's suggestion from the pre-RFC thread: a flag telling the CLI to treat its stdin (or the next file argument) as pure-PHP, equivalent to a .phpc file. This is the natural way to pipe code without the ceremony of echo “<?php …” | php. Deliberately separated from this RFC to keep scope tight; will be proposed as a follow-up RFC if this one passes.
  • Tokenizer flag for tag-less strings (working name TOKEN_PARSE_PURE) so userland tools can tokenize tag-less code without manual <?php prefixing. Follow-up RFC.
  • Framework adoption as the default for generated code (Symfony Maker, Laravel Artisan, etc.) – downstream, outside the language's scope.
  • IDE / static-analyzer support. Major tools (PhpStorm, Psalm, PHPStan, PHP-CS-Fixer) recognise file modes by extension via their respective config files. Adding .phpc should be a small downstream change in each, but is not in this RFC's scope to coordinate; the author commits to opening tracking issues in each project on the day the RFC passes.
  • Webserver-config scanner / warning when .phpc files appear inside an Apache/Nginx DocumentRoot without an explicit handler mapping. Belt-and-braces defence against the SOURCE-CODE-EXPOSURE failure mode discussed in the Security section.

Rejected Alternatives

  • First-byte auto-detect on .php files (“if the file does not start with <, treat it as pure PHP”). Considered and prototyped – produces 0.08% test-suite BC hits in php-src, all in test fixtures – but the BC class is non-zero, and the principle that file extension is the carrier of file-format intent is cleaner. Rejected on BC grounds.
  • PHP_INPUT_PURE=0|1 environment variable (Alex Rock). Rejected: scope is wrong (the environment affects every PHP invocation in the process tree, but the property “this file is pure PHP” is per-file, not per-process). Also fails for FPM workers serving multiple requests.
  • include_pure / require_pure keywords (Alex Rock). Rejected: forces the caller to know whether the callee is template-shaped or pure-shaped. The callee's nature should not propagate into every require site. PHP autoloaders would have to dispatch by inspecting the file before requiring it, defeating the purpose.
  • declare(pure=1); first-statement marker. Rejected: still ceremony, and worse than <?php because it must come AFTER an implicit <?php – chicken-and-egg.
  • Magic first line (e.g. #!php) Rejected: indistinguishable from a real shebang to many tools, and invisible in the file extension where most tooling already configures behavior.
  • Global php.ini toggle. Rejected, see Ohgaki 2014.
  • SAPI-only handler mapping (Ben Ramsey). Rejected as insufficient, see “Why engine-level dispatch” above. The -p CLI flag is adopted as complementary.
  • Bundle with a JavaScript-strict-mode-style language cleanup (Michael Morris). Rejected as a category error: .phpc is about the file's parsing entry-point; a strict mode is about runtime semantics. The two are orthogonal axes - a future strict mode naturally lives in declare() or an attribute, works in both .php and .phpc, and consumes none of the design space .phpc uses. Bundling would force every .phpc adopter into strict mode whether they want it or not, and would extend Discussion/Voting by months while the strict-mode design is litigated. PHP already has multiple per-file opt-ins that landed individually (declare(strict_types=1), declare(ticks=1), readonly classes, enums); there is no scarce “one-shot” file-mode slot.

Proposed Voting Choices

Two simple yes/no votes (per language-change RFC convention, both requiring 2/3 majority):

  1. Primary vote: introduce .phpc as an opt-in pure-PHP file extension as specified above.
  2. Sub-vote (if primary passes): which extension name? .phpc / .phpp / .pcode / .phpx – plurality wins.

Patches and Tests

  • Implementation: Zend/zend_language_scanner.l +53 / -2 in two functions: a ~50-line block in open_file_for_scanning performing the extension check, BOM/shebang skip, and state selection; plus a one-line use of the captured starting line number where CG(zend_lineno) is reset. Regenerated zend_language_scanner.c (built by re2c at compile time) is gitignored upstream and not part of the patch.
  • Tests: Zend/tests/phpc/ – 15 new .phpt tests covering basic pure-PHP, the .php/.phpc BC sanity check, mixed require chains in both directions, UTF-8 BOM, __halt_compiler(), closing tag drop-out, empty files, declare(strict_types=1), namespaces+classes, eval() invariance, token_get_all() invariance, CLI shebang, literal <?php as syntax error, and extension-match strictness (.phpcc / _phpc.php must NOT be matched).
  • Regression: full Zend/, ext/tokenizer/, ext/standard/, ext/spl/, ext/reflection/, ext/phar/ (9836 tests) – 0 failures, 0 modifications to pre-existing tests.

Implementation

After this RFC is implemented, this section should contain:

  • the version(s) it was merged into
  • a link to the git commit(s)
  • a link to the PHP manual entry for the feature

References

Appendix A — Canonical webserver configurations

Apache + mod_php

# In httpd.conf or .htaccess
AddHandler application/x-httpd-php .phpc
# OR, if you want .phpc never served:
<FilesMatch "\.phpc$">
    Require all denied
</FilesMatch>

Nginx + PHP-FPM

location ~ \.phpc$ {
    fastcgi_pass   unix:/run/php/php-fpm.sock;
    fastcgi_index  index.phpc;
    include        fastcgi_params;
    fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

Caddy

php_fastcgi /path/to/php-fpm.sock {
    try_files {path} {path}/index.phpc {path}/index.php =404
}

FrankenPHP

FrankenPHP builds on Caddy and inherits the same matcher syntax. The Caddy block above applies. For the embedded php_server shorthand, the file-extension filter is set indicatively as:

php_server {
    # match .phpc alongside the default .php
    file_extensions phpc php
}

The exact directive name should be confirmed against the FrankenPHP documentation for the target version when the manual entry is written; the principle (route .phpc alongside .php, or explicitly deny it) is the part this RFC asserts.

rfc/optional_php_tags.txt · Last modified: by hmennen