Services & DI#
Relayer uses Symfony's DI container, with autowire + public as the default. There are two registration methods, and they can be combined.
config/services.yaml#
services:
_defaults:
autowire: true
autoconfigure: true
public: true
App\Service\PdoUserRepository: ~
App\Service\UserRepository:
alias: App\Service\PdoUserRepositoryThe .yaml / .yml / .php formats are supported. A definition registered with no arguments automatically gets autowire and public (retrievable via PSR-11's get($id)).
AppConfigurator#
<?php
namespace App;
use Polidog\Relayer\AppConfigurator as BaseConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class AppConfigurator extends BaseConfigurator
{
public function configure(ContainerBuilder $container): void
{
$container->register(PdoUserRepository::class);
$container->setAlias(UserRepository::class, PdoUserRepository::class)
->setPublic(true);
}
}Pass it like Relayer::boot(__DIR__ . '/..', new App\AppConfigurator(__DIR__ . '/..'))->run();. It runs after services.yaml, so it can override as well. The project root is available as $this->projectRoot, and the %app.project_root% parameter can also be used.
Factories (choosing the implementation at runtime)#
This site's DocStore is created with a factory in order to switch the connection target by environment variable.
services:
App\Docs\DocStore:
factory: ['App\Docs\DocStoreFactory', 'create']
arguments:
- '%app.project_root%'
autowire: falseDocStoreFactory::create() returns Turso if TURSO_DATABASE_URL is present, otherwise local SQLite. Pages and route.php only need to type-declare DocStore as an argument to have it injected.
Passing environment variables as service arguments (%env()%)#
In services.yaml you reference environment variables via the standard Symfony %env(VAR)% placeholder.
services:
App\Logger\NewRelic:
arguments:
$apiKey: '%env(NEW_RELIC_API_KEY)%'
$appId: '%env(NEW_RELIC_APP_ID)%'Symfony's standard prefixes give typed or fallback resolution: %env(int:DB_PORT)%, %env(bool:FEATURE_FLAG)%, %env(default::API_TOKEN)%, and so on. To pass an env value from AppConfigurator, hand the same placeholder to setParameter:
final class AppConfigurator extends Base
{
public function configure(ContainerBuilder $container): void
{
$container->setParameter('app.api_token', '%env(API_TOKEN)%');
}
}%env(VAR)% resolves in both dev and production. In dev the live ContainerBuilder reads the current env at compile time; in production under container:compile (see CLI) the dumped class issues a getEnv('VAR') call that reads the current env on every request.
container:compile and runtime secrets#
container:compile runs AppConfigurator::configure() exactly once, at deploy time, and bakes the resulting container into a dumped class. Production boot just requires the dump; AppConfigurator never runs again.
A %env(VAR)% placeholder is dumped as a getEnv('VAR') call, so it keeps tracking the live env on each request. But if AppConfigurator reads env directly and passes a plain string to setParameter() — e.g. $_ENV['API_TOKEN'] — that string is frozen into the dump and prod never re-evaluates the env.
So when a secret only becomes available at runtime — Fly secrets, Cloud Run env, sidecar injectors — and the env is empty at build time, the empty string is baked in and prod silently keeps returning empty. The fix is to stop reading env directly and pass the %env(VAR)% placeholder instead:
// Anti-pattern — empty at build time bakes empty forever
$container->setParameter('app.api_token', $_ENV['API_TOKEN'] ?? '');
// Recommended — dumped as getEnv('API_TOKEN'), resolved per request
$container->setParameter('app.api_token', '%env(API_TOKEN)%');routes:compile is unaffected (it only walks src/Pages/ and never touches env). Only container:compile interacts with env values. If runtime secrets cannot be expressed as %env()% for any reason, dropping container:compile and keeping just routes:compile — paying the per-request compile() cost — is a reasonable alternative (dev already builds live and never hits this problem).
Framework-owned variables like
DATABASE_*/APP_LOCALEfollow a different path:ContainerFactoryreads$_ENVdirectly in PHP and turns them into plain parameters / constructor arguments. Undercontainer:compilethey are baked at build time the same way, so if any of them is only injected at runtime (e.g. a Fly secret forDATABASE_PASSWORD),container:compilehas to be dropped (see Environment variables and .env cascade).
Autowiring#
Page / API handler arguments are autowired by type: PageContext, Request, Identity, and registered services.
Pages, layouts and error pages are rebuilt per request#
The container makes the classes declared in the page, layout and error files under src/Pages (including classes that only implement Renderable), classes extending PageComponent or ErrorPageComponent, and classes implementing LayoutInterface non-shared services. This holds when you register them in AppConfigurator, when you set setShared(true), and in a container dumped with container:compile. So an object a page builds in its constructor (a form, a DTO) never carries state over to the next visitor in a long-running worker. Injected services are the ones the container shares, and other Renderable services such as components stay shared too.
An object handed to the container with set() cannot be rebuilt, so the router renders a shallow clone of it. Objects the clone holds are shared between requests, and a class that cannot be cloned (one with a private __clone()) makes the request fail with a RuntimeException. Do not register pages this way.
Getting services from PSX components#
PSX components under src/Components/ are plain closures, so constructor / argument injection is not available. Inside a component, get services directly from the DI container with Relayer::container().
<?php
// src/Components/TodoList.psx
namespace App\Components;
use Polidog\Relayer\Db\Database;
use Polidog\Relayer\Relayer;
use Polidog\Rosanna\Runtime\Element;
return function (array $props): Element {
$db = Relayer::container()->get(Database::class);
$todos = $db->fetchAll('SELECT id, title, done FROM todos ORDER BY id');
return <ul>{array_map(fn ($t) => <li>{$t['title']}</li>, $todos)}</ul>;
};Relayer::container() returns the PSR-11 container built by boot() (calling it before boot() throws a LogicException). It is an escape hatch for components only. For ordinary services such as pages, API handlers and route classes, constructor injection through the autowiring / AppConfigurator above is the proper way — and when you want to pass a value to a component, consider props first.
Revisions (4)
- v1.1.0: usePHP→Rosanna(rosanna-defer)に合わせて更新
- v0.20.0 — %env() resolves in dev and prod; rewrite for container:compile bake warning
- Document %env() placeholders in services.yaml + container:compile caveat (dev/live ContainerBuilder leaks env_<hash>_VAR_<hash>; production dumped container resolves via getEnv()). Add AppConfigurator
- Created