PHP and Symfony¶
SymfonyService extends PHPService with Symfony-specific tasks (console,
cache, migrations, Twig CS). Use PHPService for any other PHP application.
Configuration¶
(new SymfonyService('app')) // Service name, the only constructor argument
->withDirectory(__DIR__) // Application directory (default: '.')
->withVersion('8.5') // PHP version (default: 8.5)
->withMode(PhpMode::FrankenPhp) // PhpMode::FrankenPhp (default) or PhpMode::Fpm
->withDatabaseService($databaseService)
->withMailerService($mailpitService)
->withDomain('app.example.test', 'example.test')
->withHttpAccess() // Also serve plain HTTP, without redirecting to HTTPS
->addExtension('redis') // Adds "php{version}-redis" (fpm) or the FrankenPHP equivalent
->addWorker('messenger', 'php bin/console messenger:consume async', 'unless-stopped')
->withFrankenPhpWorkerMode('public/index.php', num: 4)
Applications inside a monorepo¶
withDirectory() is what gets mounted; withWorkingDirectory() names the
application inside it. They only coincide when the application owns its
directory:
(new SymfonyService('backend'))
->withDirectory(__DIR__) // mount the repository root
->withWorkingDirectory('apps/backend') // the application lives here
Composer, the console and the QA tools then run in /var/www/apps/backend, and
the document root of the frontend follows — the web server configuration is
built with the application directory, not with a fixed /var/www/public.
Sharing one builder container¶
Three applications of the same repository do not need three identical
-builder containers:
$backend = (new SymfonyService('backend'))
->withDirectory(__DIR__)
->withWorkingDirectory('apps/backend');
$demo = (new SymfonyService('demo'))
->withDirectory(__DIR__)
->withWorkingDirectory('apps/demo')
->withSharedBuilder($backend); // no "demo-builder" container
castor demo:install, castor demo:composer and castor demo:symfony then run
in backend-builder, with the working directory set to apps/demo. The shared
builder has to mount a directory containing both applications, which is what
mounting the repository root gives you.
->withoutBuilder() generates no builder container at all and runs those tasks
in the application container itself — only enough if it carries the tooling.
Runtime modes¶
PhpMode::FrankenPhp(default) — serves the application with FrankenPHP (dunglas/frankenphpimage + Caddy). A single process, no PHP-FPM and no nginx.PhpMode::Fpm— the classic nginx + PHP-FPM stack.
Only the frontend container differs between modes: the builder and worker
containers are identical either way. Known limitation of PhpMode::FrankenPhp:
there is no /php-fpm-status monitoring endpoint.
Extensions¶
These extensions are installed by default: apcu, bcmath, curl, iconv,
intl, mbstring, pgsql, uuid, xml, zip. Add more with
->addExtension('name') — no custom Dockerfile needed.
FrankenPHP worker mode¶
->withFrankenPhpWorkerMode(string $script = 'public/index.php', ?int $num = null, bool $watch = true)
Boots $script once and keeps it in memory to handle every request, instead of
re-interpreting it per request. Only takes effect in PhpMode::FrankenPhp. Your
application needs a compatible runtime to loop over incoming requests — for
Symfony, install
runtime/frankenphp-symfony
and point $script at public/index.php.
$watch (on by default) restarts the worker when files under the application
directory change, which is what you want locally.
Dockerfile extension points¶
Both services are built from a Twig Dockerfile you can extend to override one part of the image and keep the rest. Which file to extend depends on the mode:
# syntax=ghcr.io/castor-php/twig-dockerfile:0.1
{% extends 'Dockerfile.frankenphp' %} {# PhpMode::Fpm: extend 'Dockerfile' #}
Then point the service at your file with
->withDockerfile(__DIR__ . '/Dockerfile').
Blocks¶
| Block | Stage | Container | What it holds |
|---|---|---|---|
php_base |
php-base |
— | Debian + PHP CLI from sury, with the extensions and the base ini files |
frontend |
frontend |
app |
FrankenPHP, or nginx + PHP-FPM in PhpMode::Fpm |
worker |
worker |
app-worker-{name} |
php-base, nothing more |
builder |
builder |
app-builder |
Composer, PIE, Node, git, make and the shell completion |
worker and builder always start FROM php-base, so a change in php_base
reaches them in both modes. The frontend only does in PhpMode::Fpm — in
FrankenPHP mode it starts from the dunglas/frankenphp image instead, and needs
its own step.
Variables¶
| Variable | Type | Comes from |
|---|---|---|
php_version |
string | withVersion() (default 8.5) |
php_extensions |
list of strings | the default list plus addExtension() |
app_root |
string | withWorkingDirectory(), as a path under /var/www; absent when the application owns its directory |
frankenphp_worker_file |
string | withFrankenPhpWorkerMode(), as a path under /var/www |
frankenphp_worker_num |
int | its $num argument, only when you pass one |
frankenphp_worker_watch |
bool | its $watch argument |
The three frankenphp_* ones only exist when worker mode is on, so guard them
with {% if frankenphp_worker_file is defined %}.
Configuration templates¶
The frontend block renders these with copy(). They are Twig templates too,
so a project can extend them instead of rewriting them:
| Mode | Template | Written to | Blocks |
|---|---|---|---|
PhpMode::Fpm |
frontend/etc/nginx/nginx.conf.twig |
/etc/nginx/nginx.conf |
root, http, server, server_locations, events |
PhpMode::FrankenPhp |
frontend-frankenphp/Caddyfile.twig |
/etc/frankenphp/Caddyfile |
none, replace the whole file |
Files in the build context¶
COPY in your blocks reads from the plugin's Resources/php directory, not
from your project — see where files come
from. What is
there: base/php-configuration/, builder/php-configuration/,
frontend/php-configuration/, frontend/etc/ and base/sudo.sh.
The Dockerfile cookbook turns all of this into concrete recipes.
Quality assurance¶
castor app:qa:phpstan # PHPStan
castor app:qa:cs # PHP CS Fixer
castor app:qa:rector # Rector
castor app:qa:twig-cs # Twig CS Fixer (SymfonyService only)
The tools are installed on the host by castor and run inside the builder
container, so they see the PHP version, the extensions and the vendor/ the
application actually runs on — not whichever PHP happens to run castor. PHPStan
resolving a class against the wrong PHP version, or PHP CS Fixer warning that
your host PHP is newer than the one your composer.json supports, both go away.
They are installed in .castor/vendor/.tools/, which the builder container
mounts at /castor-tools. Nothing is added to the image, so changing a tool
version does not mean rebuilding it.
Each application gets its own installation, named after it — app-phpstan,
backend-rector. Two applications of the same repository pinning different
versions therefore keep one each, instead of reinstalling over each other on
every run, and bumping a version reinstalls in place rather than leaving the
previous one behind.
Each task forwards its arguments, and returns the tool's exit code:
castor app:qa:phpstan --level=8 src
castor app:qa:cs --dry-run
What gets analysed is the tool's own business whenever the application
configures it. A phpstan.neon, a .php-cs-fixer.php or a rector.php in the
working directory of the application means the task runs the tool with no path
at all, so the paths of your PHPStan configuration, the finder of your PHP CS
Fixer one and the withPaths() of your Rector one are what decide.
That is not a detail: none of these tools treats a path on the command line as a
restriction of its configured paths, it replaces them. Passing the application
directory by default would analyse vendor/ and var/ along with the sources of
an application whose phpstan.neon says paths: [src] — while still reporting
that configuration file as used, since everything else in it does apply.
Only an application configuring nothing gets a path from the plugin: the
application directory for PHPStan, its src/ for PHP CS Fixer and Rector, which
write rather than report.
Note
Composer resolves the tools against the PHP version running castor, not the
one in the container, so the version it picks has to be able to run on both.
Pin it with withPhpStanVersion() & co if your host PHP is far ahead of the
one the application runs.
Pin the tool versions and add PHPStan extensions from the service:
(new SymfonyService('app'))
->withPhpStanVersion('^2.0')
->withPhpCsFixerVersion('^3.0')
->withRectorVersion('^2.0')
->withPhpTwigCsFixerVersion('^3.0') // SymfonyService only
->addPhpStanExtraDependency('phpstan/phpstan-symfony', '^2.0')
Generated tasks¶
Shared by both services:
castor app:bash— a bash shell in the builder containercastor app:install—composer installcastor app:composer— any Composer commandcastor app:qa:phpstan,castor app:qa:cs,castor app:qa:rector
Only when the application declares workers:
castor app:worker:restart [worker]— restart them all, or the one namedcastor app:worker:stop [worker]— stop them all, or the one named
SymfonyService adds:
castor app:symfony— any Symfony console commandcastor app:cache-clear,castor app:cache-warmupcastor app:db:migrate,castor app:db:fixturescastor app:qa:twig-cs
Containers¶
app— the frontend, FrankenPHP or nginx + PHP-FPM depending on the modeapp-builder— the builder container, on thebuilderprofileapp-worker-{name}— one per background worker