Migrating from docker-starter¶
The migration is mostly a deletion. What follows is the whole of
infrastructure/docker/ and .castor/ going away, replaced by a castor.php
of a few dozen lines.
What replaces what¶
| docker-starter | Here |
|---|---|
infrastructure/docker/docker-compose.yml |
compose.generated.yaml, from your castor.php |
infrastructure/docker/docker-compose.override.yml |
compose.override.yaml, still yours |
infrastructure/docker/services/php/Dockerfile |
shipped by the plugin, extensible |
infrastructure/docker/services/router/ (Traefik) |
the global Caddy router |
.castor/docker.php |
Castor\Docker\ tasks and functions |
.castor/context.php |
two or three context variables |
.castor/postgres.php |
PostgresService and its postgres:client task |
.castor/qa.php + tools/* |
the QA tasks of PHPService |
.castor/worktree.php |
a project name per checkout |
.castor/init.php |
castor docker:service:install |
Before you start¶
Work on a branch, and dump your database first if needed. The named volumes
are not carried over: docker-starter names them postgres-data, the plugin names
them postgres_data, and the compose project name may change too — so the new
stack starts on an empty database.
docker compose -p app exec -T postgres pg_dump -U app -Fc app > ../app.dump
Replace app with your project_name. You will restore it at the end.
1. Install the plugin¶
castor composer require castor-php/docker
2. Describe the stack¶
The default docker-starter stack is a PostgreSQL, an nginx + PHP-FPM frontend, a
builder and an optional messenger worker, with the repository root mounted at
/var/www and the application living in application/. All of it:
<?php
namespace project;
use Castor\Attribute\AsContext;
use Castor\Attribute\AsListener;
use Castor\Context;
use Castor\Docker\Event\RegisterServiceEvent;
use Castor\Docker\Service\PhpMode;
use Castor\Docker\Service\PostgresService;
use Castor\Docker\Service\SymfonyService;
#[AsContext(default: true)]
function default_context(): Context
{
return new Context([
'root_domain' => 'app.test',
'registry' => 'ghcr.io/mycompany/myproject',
]);
}
#[AsListener(RegisterServiceEvent::class)]
function register_service(RegisterServiceEvent $event): void
{
$postgres = (new PostgresService())->withVersion('16');
$event->addService($postgres);
$event->addService(
(new SymfonyService('app'))
->withDirectory(__DIR__) // what is mounted at /var/www
->withWorkingDirectory('application') // where the application lives
->withVersion('8.5')
->withMode(PhpMode::Fpm)
->link($postgres)
->withDomain('app.test', 'www.app.test')
->addWorker('messenger', 'php -d memory_limit=1G bin/console messenger:consume async --memory-limit=128M')
);
}
A few things map differently:
project_nameandextra_domainsare gone. Every domain is an argument ofwithDomain(), and the compose project name comes from thename:of thecompose.yamlthe plugin creates on its first run.php_versionis a service setting,withVersion(), not a global. Two applications can run two versions.PhpMode::Fpmkeeps nginx + PHP-FPM, which is what docker-starter serves. It is worth tryingPhpMode::FrankenPhp— the plugin's default — once the migration is done, but change one thing at a time.- The PHP extensions move out of the Dockerfile and into
addExtension().apcu,bcmath,curl,iconv,intl,mbstring,pgsql,uuid,xmlandzipare already there. DATABASE_URLis injected bylink(), so thesedon.envdocker-starter does at install time has nothing left to do. The URL is the same one:postgresql://app:app@postgres:5432/app.
If you would rather not write it by hand, castor docker:service:install symfony
and castor docker:service:install postgres write those lines for you — see
installing services.
Note
Workers are on the default profile here, not on a worker one. docker:up
starts them with the rest of the stack, and there is no docker:worker:start
to remember — castor app:worker:restart starts a stopped worker too.
3. Drop the Traefik router¶
The router is global: one Caddy instance, living in ~/.castor/docker/,
serves every Castor Docker project on the machine. docker:up starts it,
docker:stop stops it once no routed container is left running anywhere.
Delete infrastructure/docker/docker-compose.dev.yml and
infrastructure/docker/services/router/ — the Dockerfile, generate-ssl.sh,
openssl.cnf, traefik/ and certs/ all go — along with the traefik.* labels
and the docker:generate-certificates task. The plugin mints certificates on
demand from the mkcert CA, so there is nothing to generate ahead of time and
nothing to regenerate when you add a domain.
Two consequences worth knowing:
PROJECT_HTTP_PORT& co. disappear, and with them.castor/worktree.php, its crc32 port hashing andcastor docker:ports. One router binds 80 and 443 for every project at once, so two checkouts no longer contend for them. Give each one its own project name and domain instead — see running two checkouts side by side.castor docker:aboutreads the compose labels, not the Traefik admin API, so it answers with the infrastructure stopped and without a Docker daemon.
See router and HTTPS for the details.
4. Drop the Dockerfile, or extend it¶
infrastructure/docker/services/php/Dockerfile has an equivalent in the plugin,
with the same stages. If you never touched it, delete the whole
infrastructure/docker/services/php/ directory.
If you did, extend the plugin's Twig Dockerfile rather than copying it back — you override one block and keep the rest:
| docker-starter stage | Block to extend |
|---|---|
base |
php_base |
frontend |
frontend |
worker |
worker |
builder |
builder |
# syntax=ghcr.io/castor-php/twig-dockerfile:0.1
{% extends 'Dockerfile' %} {# 'Dockerfile.frankenphp' in PhpMode::FrankenPhp #}
{% block builder %}
{{ parent() }}
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils
{% endblock %}
Then point the service at it with ->withDockerfile(__DIR__ . '/Dockerfile').
The Dockerfile cookbook covers the
usual customisations.
Your nginx configuration has a counterpart too: frontend/etc/nginx/nginx.conf.twig
is a template with root, http, server, server_locations and events
blocks, so a project that only changed a location extends that instead of
shipping a whole nginx.conf.
.home/ stays exactly as it is — same directory, same role.
5. Move the QA tooling¶
tools/php-cs-fixer/, tools/phpstan/, tools/twig-cs-fixer/ and the
tools/bin/* shims all go, and so do qa:install and qa:update. The plugin
installs the tools itself, into .castor/vendor/.tools/, and runs them in the
builder container so they see the PHP version, the extensions and the
vendor/ the application actually runs on.
Pin the versions your tools/*/composer.json used, on the service:
(new SymfonyService('app'))
->withPhpStanVersion('^2.0')
->withPhpCsFixerVersion('^3.0')
->withPhpTwigCsFixerVersion('^3.0')
->addPhpStanExtraDependency('phpstan/phpstan-symfony', '^2.0')
Move your configuration files into the application directory. docker-starter
keeps phpstan.neon and .php-cs-fixer.php at the repository root and runs the
tools from /var/www; here each task runs in the working directory of its
application, and looks for its configuration there:
| Tool | Files it looks for |
|---|---|
| PHPStan | .phpstan.neon, phpstan.neon, .phpstan.neon.dist, phpstan.neon.dist, .phpstan.dist.neon, phpstan.dist.neon |
| PHP CS Fixer | .php-cs-fixer.php, .php-cs-fixer.dist.php |
| Rector | rector.php |
So phpstan.neon moves to application/phpstan.neon, and its paths: become
relative to it. When a configuration file is there, the task passes no path at
all and lets the tool's own configuration decide what to analyse — see
quality assurance for why that matters.
The tasks are namespaced per application, so castor cs becomes
castor app:qa:cs, castor phpstan becomes castor app:qa:phpstan, and
castor twig-cs becomes castor app:qa:twig-cs. castor app:qa:rector comes
for free.
6. Port your own tasks¶
Everything that lived in .castor/ and called into docker\ needs its imports
changed, and the helpers take a string command and an explicit service rather
than an array and a default:
| docker-starter | Here |
|---|---|
docker\docker_compose_run(['bin/console', 'x']) |
Castor\Docker\docker_compose_run('bin/console x', 'app-builder') |
docker\docker_exit_code(['vendor/bin/phpunit']) |
Castor\Docker\docker_exit_code('vendor/bin/phpunit', 'app-builder') |
docker\docker_compose_exec(['psql'], service: 'postgres') |
Castor\Docker\docker_compose(['exec', 'postgres', 'psql']) |
docker\docker_compose([...]) |
Castor\Docker\docker_compose([...]) |
docker\build(), docker\up(), docker\stop() |
Castor\Docker\build(), up(), stop() |
docker\get_service_names() |
Castor\Docker\get_compose_service_names() |
use function Castor\Docker\docker_compose_run;
docker_compose_run('bin/console app:import', 'app-builder');
docker_compose_run(
'bin/replay --verbose',
service: 'app-builder',
workDir: '/var/www/application',
environment: ['APP_DEBUG' => '1'],
);
The builder container is named after the application — app-builder, not
builder — and a failing command raises a RuntimeException naming the service
and the command, instead of the bare docker compose error. Use
docker_exit_code(), which takes the same arguments, when you want the exit
code instead.
Anything you were running through castor builder -- <command> has a task now:
castor app:bash for a shell, castor app:composer <args> for Composer,
castor app:symfony <args> for the console.
7. Shrink the context¶
Most of .castor/context.php has no reason to exist any more. user_id is
computed by the plugin, docker_compose_files is replaced by the generated file
and your compose.override.yaml, root_dir by castor's own, and macos /
power_shell by nothing at all. What is worth keeping is root_domain,
registry, and project_name if you run several checkouts.
The test and ci contexts have no built-in equivalent — keep yours if you use
them, minus the docker_compose_files juggling:
#[AsContext(name: 'ci')]
function ci_context(): Context
{
return default_context()->withEnvironment(['COMPOSE_ANSI' => 'never']);
}
Where docker-starter swapped compose files per environment, use
profiles instead: castor docker:up --profiles default.
8. Restore your data and check¶
castor docker:build
castor docker:up
docker compose exec -T postgres pg_restore -U app -d app --clean --if-exists < ../app.dump
castor docker:about
docker:about lists every URL the project answers on, with the service serving
each and whether it runs. If the router is stopped it says so.
9. Delete what is left¶
git rm -r infrastructure/ .castor/docker.php .castor/context.php \
.castor/postgres.php .castor/qa.php .castor/worktree.php \
.castor/init.php tools/
And in .gitignore, replace
/infrastructure/docker/docker-compose.override.yml
/infrastructure/docker/services/router/certs/*.pem
with
/compose.generated.yaml
/.castor/vendor/
Keep compose.yaml and compose.override.yaml in the repository:
compose.generated.yaml is rewritten on every run, the other two are yours.
10. Update the CI¶
The hadolint job has nothing left to lint. The rest is renames:
- name: Build and start the infrastructure
run: |
castor docker:build
castor docker:up
castor app:install
- name: Check PHP coding standards
run: castor app:qa:cs --dry-run
- name: Run PHPStan
run: castor app:qa:phpstan
DS_PHP_VERSION and DS_REGISTRY become whatever your context reads — the
registry variable, and withVersion() for the PHP version.
Task equivalence¶
| docker-starter | Here |
|---|---|
castor about |
castor docker:about |
castor build, up, stop, logs, ps, destroy, push |
same, under docker: |
castor builder -- <cmd> |
castor app:bash, castor app:composer |
castor docker:generate-certificates |
— the router mints them on demand |
castor docker:ports |
— one router, fixed ports |
castor docker:worker:start / stop |
castor app:worker:restart / stop |
castor pg |
castor postgres:client |
castor install |
castor app:install |
castor cache-clear, cache-warmup |
castor app:cache-clear, app:cache-warmup |
castor migrate, fixtures |
castor app:db:migrate, app:db:fixtures |
castor cs, phpstan, twig-cs |
castor app:qa:cs, app:qa:phpstan, app:qa:twig-cs |
castor init:symfony, init:sylius |
castor docker:service:install symfony |
And the ones you gain: castor docker:logs:clear, castor postgres:expose and
its equivalent on every database and broker, castor app:qa:rector, the
docker:router:* tasks, and shell completion on every service name.
What you keep writing yourself¶
Four docker-starter tasks have no equivalent yet, because they are about your
application rather than about the infrastructure. They are a handful of lines in
your own castor.php:
use Castor\Attribute\AsTask;
use function Castor\Docker\build;
use function Castor\Docker\docker_compose_run;
use function Castor\Docker\docker_exit_code;
use function Castor\Docker\up;
#[AsTask(description: 'Builds and starts the stack, then installs the application')]
function start(): void
{
build();
up();
install();
docker_compose_run('bin/console doctrine:migrations:migrate -n --allow-no-migration', 'app-builder');
}
#[AsTask(description: 'Installs the PHP and Node dependencies')]
function install(): void
{
docker_compose_run('composer install -n --prefer-dist --optimize-autoloader', 'app-builder');
if (is_file(__DIR__ . '/application/yarn.lock')) {
docker_compose_run('yarn install --immutable', 'app-builder');
} elseif (is_file(__DIR__ . '/application/package-lock.json')) {
docker_compose_run('npm ci', 'app-builder');
}
if (is_file(__DIR__ . '/application/importmap.php')) {
docker_compose_run('bin/console importmap:install', 'app-builder');
}
}
#[AsTask(description: 'Runs PHPUnit', namespace: 'app:qa')]
function phpunit(): int
{
return docker_exit_code('vendor/bin/phpunit', 'app-builder');
}
#[AsTask(description: 'Runs a security audit', namespace: 'app:qa')]
function security_audit(): int
{
return docker_exit_code('composer audit', 'app-builder');
}
castor app:install already runs composer install, so keep your own install
only if you have Node dependencies or an importmap to install alongside it.
Node and Yarn are in the builder image either way, so nothing else is needed for the frontend part.