Write XPHP — PHP 8.4 with native generics via compile-time monomorphization — inside a Symfony application, and have the auto-generated vanilla PHP produced as part of your normal build/deploy.
The bundle wires xphp's transpiler into the Symfony lifecycle:
- a
xphp:compileconsole command to compile on demand during development; - a cache warmer so
bin/console cache:warmup(run by every production build) emits the generated PHP as a deterministic deploy artifact; - a runtime autoloader for the generated
XPHP\Generated\*classes, so the app boots against fresh output without acomposer dump-autoload.
- PHP
^8.4 - Symfony
^8.0 xphp-lang/xphp^0.1(pulled in automatically)
composer require xphp-lang/xphp-symfony-bundleRegister the bundle (Symfony Flex does this for you):
// config/bundles.php
return [
// ...
Xphp\SymfonyBundle\XphpBundle::class => ['all' => true],
];Defaults (override only what you need):
# config/packages/xphp.yaml
xphp:
# Where your .xphp sources live.
source: '%kernel.project_dir%/xphp'
# Rewritten, vanilla .php files (generic call sites replaced).
target: '%kernel.project_dir%/var/xphp/dist'
# Generated specialized classes (XPHP\Generated\*).
cache: '%kernel.project_dir%/var/xphp/generated'
# Hex length (16-64) of the hash in specialized class names. null = xphp default.
hash_length: null
# Compile automatically during cache:warmup (recommended for deploys).
compile_on_warmup: true
# Register an SPL autoloader for XPHP\Generated\ at boot.
register_runtime_autoloader: trueThese map directly to the upstream CLI: xphp compile <source> <target> <cache>.
A .xphp source uses standard PSR-4 namespace-to-directory mapping. Compilation
produces two outputs:
| Output | Namespace | Goes to |
|---|---|---|
| Rewritten user code | original (e.g. App\…) |
target dir |
| Specialized classes | XPHP\Generated\… |
<cache>/Generated/ |
The bundle autoloads XPHP\Generated\* at runtime out of the box. For the
rewritten code you choose where the application loads App\ from. For a
production deploy, point composer at the compiled output and dump an optimized
classmap:
composer dump-autoload --optimize --classmap-authoritativeWhen composer owns the classmap, you can set register_runtime_autoloader: false.
-
Put your generics under the
sourcedirectory, e.g.:xphp/ └── App/ └── Collections/ └── Collection.xphp # final class Collection<T> { ... } -
Compile during development:
bin/console xphp:compile
…or just warm the cache (also runs the compiler):
bin/console cache:warmup
-
Reference the generated classes from your app as usual; instantiations like
Collection<User>are monomorphized to concrete classes with native type hints and zero runtime penalty.
Service-like generics — Repository<User>, CachedFinder<User>, Handler<Cmd> —
can be registered in the container and autowired. This is opt-in: declare each
instantiation under xphp.services.bindings. Value-type generics (Box<T>,
Collection<T>) are not meant for DI — keep new-ing those.
# config/packages/xphp.yaml
xphp:
services:
_defaults: # inherited by every binding below
autowire: true
autoconfigure: true
public: false
shared: true
bindings:
- template: 'App\Generic\CachedFinder'
args: ['App\Entity\User'] # FQN string, or nested { template, args }
# any of autowire/autoconfigure/public/shared/tags/alias may be set per bindingThe bundle resolves each binding to the class the transpiler monomorphizes it into
(XPHP\Generated\…\T_<hash>, via the transpiler's own Registry::generatedFqn) and
registers it as a service under that class name.
The class that consumes the generic must itself be .xphp — only .xphp can
name CachedFinder<User> as a type. Write that consumer (a service, a command, a
controller) in .xphp, register it as a service, and inject the generic directly;
its compiled constructor type is the hash class, so autowiring matches with no
seam needed:
// src/Command/FindCommand.xphp (a .xphp class — register it as a service)
#[AsCommand('app:find')]
final class FindCommand extends Command
{
public function __construct(private readonly CachedFinder<User> $finder) { // -> the specialization
parent::__construct();
}
}(If a specialization does implement a stable interface, set alias: on the binding
to that interface so handwritten .php can inject it — but a generic implementing a
type-specific interface is usually a smell.)
Declaring a binding makes compilation run at container-build time (so the
generated classes exist for autowiring/autoconfigure), and the container is rebuilt
when any .xphp source changes. No separate xphp:compile step is needed.
- Write collaborator types fully-qualified in the template (no
use): monomorphization fully-qualifies type parameters but copies other type references verbatim, so a shortuse-imported name would resolve into the generatedXPHP\Generated\…namespace. Write\App\Finder\SourceInterface, not ausedSourceInterface. - You don't need a call site. A generic that's only injected is never written as
new Foo<Bar>()anywhere, yet monomorphization is usage-driven. The bundle bridges this by synthesizing a throwaway instantiation for each declared binding at compile time (transparent; cleaned up afterwards). (Stopgap: a future xphp release may accept specialization roots directly.)
Autowire the specialization (or its alias), not the bare template name —
App\Generic\CachedFindercompiles to an empty marker interface that every specialization implements, so it is ambiguous across type arguments.
A typical build step needs nothing xphp-specific beyond what you already run:
composer install --no-dev --optimize-autoloader
bin/console cache:warmup --env=prod # compiles .xphp -> var/xphp/*Commit .xphp sources; treat var/xphp/ as generated (gitignored), regenerated
on each build. If you prefer to ship precompiled PHP without xphp on the target,
run xphp:compile in CI and include the target/cache dirs in the artifact.
The cache warmer is mandatory and fail-loud: if compilation fails during
cache:warmup, the command exits non-zero and the build aborts, so a broken
artifact (missing or stale XPHP\Generated\* classes) never ships.
If you'd rather drive compilation explicitly — e.g. a dedicated CI stage, or to
keep cache:warmup free of transpilation — turn the warmer off:
# config/packages/xphp.yaml
xphp:
compile_on_warmup: falseThe bundle then never compiles on its own; you own the step and must run it
before the app is deployed (and before composer dump-autoload, since the
optimized classmap is built from the generated output):
bin/console xphp:compile --env=prod # explicit, fails the pipeline on error
composer dump-autoload --optimize --classmap-authoritativexphp:compile is itself fail-loud — it exits non-zero on a compilation error —
so a CI stage running it will stop the pipeline just like the warmer would.
With the warmer disabled, cache:warmup no longer regenerates var/xphp/, so
make sure your build runs xphp:compile on every deploy; otherwise the app
boots against whatever output happens to be on disk.
A runnable, console-only example app lives in demo/. It writes .xphp
generics alongside plain PHP in one src/, compiles them through this bundle, and
calls the generated code from a Symfony command:
cd demo && composer install
bin/console xphp:compile
bin/console app:demomake test # PHPUnit
make test/mutation # InfectionMIT