Changelog¶
0.3.4 - 2026-08-12¶
Added¶
RedirectionioAgentService::withTestMode()andwithLogging(), writing thetest_modeandloggingkeys of the agent instance — a development environment can run the rules it is still testing, and keep its traffic out of the logs. Neither key is written unless asked for, so the agent's own defaults stand otherwise.- The global router starts and stops with the projects.
docker:upstarts it when the project routes a domain and it is not already running, anddocker:stop—docker:destroytoo — stops it once no routed container is left running on the machine.docker:router:enablewas a one-time setup nobody remembers on a new machine, and forgetting it is silent: the containers come up, the domains answer "connection refused" on 443, and nothing says why. A project routing no domain neither starts nor stops it, and a project that goes down while another still runs leaves it up. - The
router_autostartcontext variable and theCASTOR_DOCKER_ROUTER_AUTOSTARTenvironment variable, both turning that off — the environment wins, so a CI job or a single shell can leave the router of the machine alone without touching the project. With it off, onlydocker:router:enableanddocker:router:disablestart and stop the router.
Changed¶
docker:router:statusreports whether the autostart is on, and the projects the router currently serves.
0.3.3 - 2026-08-11¶
Added¶
castor docker:about(aliascastor about), which lists every URL the project answers on. Nothing told you what a project serves: the domains live in thecaddylabels of the generated compose file, and finding the address of a service meant reading them — or guessing from the root domain. Each URL is listed against the service serving it, and against whether that service runs. They are read fromcompose.generated.yaml,compose.yamlandcompose.override.yaml, so a domain declared by a service, by an#[AsDockerComposeBuilder]function or straight in your own compose file is listed the same way, and the task answers with everything stopped — only the running/stopped statuses need a docker daemon.
0.3.2 - 2026-08-10¶
Fixed¶
- The router watches the Docker socket of the daemon the projects run on, rather
than always
/var/run/docker.sock. It builds its routes from thecaddy.*labels it reads there, and a CI job installing a daemon of its own — as thedocker/setup-docker-actionfamily does — a rootless daemon or Colima all put their socket somewhere else. Watching the wrong one is silent: the router comes up, finds no label, serves nothing, and every routed domain answers "connection refused" on 443 — which then reads as a plain 404 from anything calling a project's own API through it.DOCKER_SOCKET_PATHis read first, then aunix://DOCKER_HOST;docker:router:enablealso warns when the socket it resolved does not exist.
0.3.1 - 2026-08-10¶
Fixed¶
- The
{app}:qa:phpstan,{app}:qa:csand{app}:qa:rectortasks let the configuration of the application decide what is analysed. They used to pass a path by default — the application directory for PHPStan, itssrc/for the two others — and none of these tools treats a path on the command line as a restriction of its configured paths: it replaces them. PHPStan only falls back toparameters.pathswhen the command line names none, PHP CS Fixer ignores the finder of its configuration unless asked for--path-mode=intersection, and Rector does the same withwithPaths(). So an application whosephpstan.neonsayspaths: [src]was analysed whole,vendor/andvar/included — while PHPStan still reported that configuration file as used, because everything else in it did apply — and a PHP CS Fixer finder coveringtests/,config/andmigrations/was cut down tosrc/. The tasks now name no path at all when the working directory of the application holds a configuration file the tool discovers on its own (phpstan.neon& co,.php-cs-fixer.php,.php-cs-fixer.dist.php,rector.php), and keep the previous fallback for an application that configures nothing. Arguments passed to the task still win over both.
0.3.0 - 2026-08-07¶
Added¶
- The containers of a project now resolve its own public domains. The router is
global and joins the project network from the outside, so nothing inside a
project used to resolve
https://api.myproject.test— an application calling its own API, a worker hitting the front end or a reverse proxy going back to the backend all failed, and Docker accepts no wildcard inextra_hoststo work around it. The plugin knows every routed domain and writes oneextra_hostsentry per domain on every service, pointing at the host gateway where the router's ports 80 and 443 answer. The domains are also passed todocker network connectas network aliases. Turn it off with theresolve_domains_via_hostcontext data. RustBuilderandGoBuilder: one compiler container for a whole repository, holding the toolchain and declaring the applications it compiles withwithApp(). Each application gets its own task namespace —<app>:build,<app>:test,<app>:cargo/<app>:go,<app>:qa:clippy,<app>:qa:fmt— all running in that one container. A monorepo no longer declares the toolchain once per binary, and the build and QA commands no longer run inside a running application container.BinaryRunService, the runtime half of the same model: it runs one compiled binary and nothing else, whatever produced it. Attaching a builder withwithBuilder()gives it the image the binary was compiled in, the same mount, and thebuildandwatchtasks.withWorkingDirectory()on every service mounting a directory. What gets mounted, where the commands run and where the binary lives are three different things in a monorepo:withDirectory()mounts,withWorkingDirectory()names the sub-directory below it, andwithBinaryPath()locates the binary. For a PHP application the document root of the frontend follows, through the newapp_rootbuild argument.PHPService::withSharedBuilder()andwithoutBuilder(), so several applications of one repository stop generating identical-buildercontainers.RustService::withTarget(), which adds--target <triple>to the build command and moves the default binary path totarget/<triple>/debug/<name>, pluswithBinaryPath(),withBuildCommand()andwithRunCommand()on bothRustServiceandGoService.- Missing compose keys on
ServiceBuilder:restart(),ulimits(),dns(),extraHost()anddeploy().environment()now takes anullvalue, which emitsKEY: null— the compose syntax passing a variable through from the environment castor runs in. docker_compose_run()anddocker_exit_code()takeenvironment,entrypointandports. A failing command is now wrapped in aRuntimeExceptionnaming the service and the command, instead of the baredocker composeerror.withName()on every service that used to hardcode its name — the databases, Redis, RabbitMQ, Elasticsearch, ClickHouse, Mailpit and the redirection.io agent — so the same one can be registered twice. A second instance was impossible before: the two would have collided on the compose service, on the named volumes and on the routed domain. Everything a service generates is now derived from its name, including the companion containers (redis-insight,clickhouse-keeper), the DSN host and the task namespace. The connect task of a database and the Kibana container keep their historical name for the first instance, so nothing moves for a project registering one of each.- Server configuration for
MySQLServiceandMariaDBService, withwithSetting(),withSettings(),withConfiguration()andwithConfigurationFile(). The three sources are merged into one file shipped as a compose config in/etc/mysql/conf.d, so its content lives in the generated compose file: no host directory for Docker to create asroot, and no file whose permissions the server might refuse. A configuration file is read when the compose file is generated, and a missing one raises instead of leaving the server silently unconfigured. RedirectionioAgentService::withApiHost()andwithApiTimeout(), writing theapisection of the generatedagent.ymlso the agent can talk to a self-hosted instance. Absent by default.ServiceBuilder::config()takesrecreateOnChange, stamping a digest of the config content in a label. Compose does not recreate a container when only the content of an inline config changed, so a server reading its configuration at boot kept running with the old one until someone thought of--force-recreate. Used by the redirection.io agent and by the MySQL-family configuration, both of which read theirs once. Left off for anything that reloads on its own.castor {app}:worker:restartandcastor {app}:worker:stop, on a PHP application declaring workers. Both take the worker name — the one passed toaddWorker()— and act on every worker when it is omitted; an unknown name is rejected with the list of those declared, rather than quietly acting on all of them.worker:restartstarts a stopped worker, so there is no separate start task. The tasks do not exist on an application without workers.castor docker:logs:clear [service], emptying the log files docker keeps for the containers sodocker:logsstarts from a clean slate. Nothing is restarted: the file is truncated in place. Stopped containers and inactive profiles are covered; a container whose logging driver is notjson-fileis reported as skipped. When the file cannot be written directly — it belongs toroot, and on Docker Desktop it lives inside the VM — a short-lived--privilegedcontainer reaches it.- Shell completion on every argument naming something, each offering the right
list: the compose containers on
docker:build,docker:up,docker:stop,docker:logsanddocker:logs:clear; the services registered incastor.phpondocker:service:remove; the available installers ondocker:service:install; and the workers of the application on{app}:worker:restartand{app}:worker:stop. The container names are read from the three compose files, so the services a project declares itself are offered alongside the generated ones and completion needs no running daemon. castor {app}:updateon every application of aGoBuilder, updating the module dependencies withgo get -u ./...and puttinggo.modandgo.sumback in order withgo mod tidy— the other half of the operation, which runs by default and is skipped with--no-tidy. Takes a module name to update a single one, and--patchto stay inside the current minor version. It runs in the builder container, so on the Go version the module is compiled with.- A restart policy for the PHP workers, as a third argument of
addWorker(). Nothing brought a worker back when it exited, so a consumer given the--time-limitthe documentation recommends ran once and stayed down until the nextdocker:up. Reaching that limit is a successful exit, so it wantsunless-stoppedrather thanon-failure. There is still no policy unless asked for. BinaryRunService::withRestart(), setting the compose restart policy —on-failureby default. Nothing watches a binary that exits, so without one it stays down until someone notices;on-failurebrings it back without fighting a deliberatedocker:stopthe wayalwayswould. Absent unless asked for.RustBuilder::withNightlyFormatter(), which installs the nightly toolchain with its rustfmt in the image and points thefmttask of every application at it, leavingbuild,test,cargoandqa:clippyon the default toolchain. Most of rustfmt's options are still unstable, so arustfmt.tomlusing any of them is silently ignored by a stable rustfmt — building on stable and formatting on nightly is the usual answer. A nightly declared withaddRustupToolchain()is completed withrustfmtrather than installed twice.RedirectionioAgentService::withDebug(), raising the agent log level and letting it accept a certificate it cannot verify when calling its API — which is what a self-hostedwithApiHost()served by the local router hands it, the agent image carrying the public CA bundle only.get_default_profiles()reads thedocker_profilescontext data instead of always returning['default'].- The Rust Dockerfile is now a Twig template with
rust_baseandruntimeblocks, and Go gets one withgo_baseandruntime. Both are extensible the way the PHP ones already were — which is how extra Debian packages, rustup components, targets and toolchains are added.
Fixed¶
- The applications behind
RedirectionioAgentServicenow receive theHostof the original request. The agent derives it from the address it forwards to — an IP keeps the original, a host name replaces it with itself — and every target here is a compose service reached by name, so the application was handedHost: app. Symfony rejects that as an untrusted host, and every absolute URL generated from it was wrong.preserve_hostis written on every forward;withPreserveHost(false)restores the agent's own behaviour, per agent or per domain. ClickhouseServiceroutes its UI to port 8123. The image exposes 8123 (HTTP) and 9000 (native protocol), and without a port Caddy picked whichever it found first — answering 502 about half the time.- The content of an inline compose config is escaped against interpolation.
Compose interpolates the file it reads, configs included, so an nginx
configuration reached the container stripped of every
$host,$uriand$document_root, with only a "variable is not set" warning to show for it.ComposeBuilder::config()takesinterpolate: truefor a config that really does mean to read${PROJECT_NAME}& co.
Changed¶
ServiceBuilder::withHttpRouting()requires the port. Without one it emitted a bare{{upstreams}}, which caddy-docker-proxy resolves against whatever the image happens to expose — the first of several, or port 80 when it exposes nothing — routing to the wrong port silently and answering 502. Every call now names it,RedisServiceincluded: the RedisInsight UI listens on 5540 and was relying on that guess. Pass the port to anywithHttpRouting()of your own.{service}:bash, and the database sessions, no longer fail outright when the environment has no terminal. They asked castor for an interactive context unconditionally, and that throws aLogicExceptionon a pipe, in CI or under an agent — socastor app:bash < script.shcould not work. The interactive flags are now only requested when they can be honoured; without a terminal the command still runs on whatever is piped into it.- The QA tasks now run inside the builder container.
{app}:qa:phpstan,{app}:qa:cs,{app}:qa:rectorand{app}:qa:twig-csused to run on the host, against whichever PHP happens to run castor — a different version, and different extensions, from the one the application runs on. The tools are still installed by castor in.castor/vendor/.tools/, but that directory is now mounted at/castor-toolsin the builder container and the tools are executed there. Each application gets its own installation —app-phpstan— so two applications of one repository pinning different versions no longer reinstall over each other on every run. The tasks return the tool's exit code instead of itsProcess. GoServicebuilds from a Dockerfile shipped by the plugin instead of running thegolangimage directly, so it can be extended and its build cache pushed like every other service. Its generatedbuildsection is new; the tasks and the runtime behaviour are unchanged.GoServiceandRustServiceare no longerfinal, their properties areprotected, andgetTasks()is split into one method per task, so a subclass can replace, remove or add a single task without redeclaring the others. The behaviour traits' properties areprotectedtoo.docker_exit_code()now forwardsportMappingtodocker_compose_run(), which it silently dropped.- The
project_namecontext data is no longer overwritten by thename:ofcompose.yaml, which is the precedenceget_project_name()documents and never applied. The plugin also exportsCOMPOSE_PROJECT_NAME, so docker compose uses the same project name it does — without it compose built in the project named by the file while the plugin looked for containers, networks and${PROJECT_NAME}-<service>images in the one named by the context. Together they make a second checkout of a repository — a git worktree — a matter of overridingproject_nameandroot_domainin its context. docker_compose_run()no longer prints the two lines compose emits for the throwaway container it creates —Container app-builder-run-8c9d8bef Creating, thenCreated— in front of the output of the command asked for. They come back with-v, where knowing which container ran is the point.docker_compose()takes aprogressargument for the same purpose.- The tasks of a named service are now called
{service}:{task}. The database sessions moved out of the shareddb:namespace and are named after what they do rather than after the client they run:db:psqlis nowpostgres:client,db:mysqlismysql:client,db:mariadbismariadb:clientanddb:clickhouseisclickhouse:client. Registering the same service twice therefore gives two full task sets —postgres:clientnext toanalytics:client— instead of adb:namespace where the second instance had to be spelled differently.
Documentation¶
- Multiple applications
covers the monorepo shape, and the
example/project is now one: two PHP applications sharing a builder, a Rust and a Go binary each built by their language's builder, and a container calling another through its public domain.
0.2.1 - 2026-07-27¶
Added¶
DockerComposeBuilderEvent, dispatched with theComposeBuilderonce every service has contributed and before the file is serialized: add a container the plugin has no service for, or change one that is already registered.DockerComposeWriteEvent, dispatched with the configuration as a plain array right before it is written: the escape hatch for the compose keys the builder does not model —deploy,logging,ulimits, thex-extension fields.#[AsDockerComposeBuilder], sugar over the first event: the function receives theComposeBuilder, optionally theContext, and takes apriority.- Documentation for the three of them, in extending the compose
file,
and an example of each in the
example/project.
Changed¶
- The plugin no longer builds its task commands from castor's internal API: the
tasks of a service are handed over as
TaskDescriptorthroughFunctionsResolvedEvent, and castor builds them.ExpressionLanguage,Slugger,TaskCommandand the consoleApplicationare no longer reached into, which leaves the event dispatcher as the only internal API still in use. castor listno longer regeneratescompose.generated.yaml. Castor boots it on a bare context by design, so the file was rewritten without the project configuration and had to be repaired by the next command.- The docker compose project name is read from the
nameofcompose.yamlrather than from the context data, so it is correct on a project declaring no#[AsContext]function — where it used to fall back to the directory name.
Documentation¶
- More detail on the Dockerfile blocks and on the PHP service.
0.2.0 - 2026-07-27¶
Changed¶
- The Caddy router is now global: a single instance, living in
~/.castor/docker/router/and shared by every project on the machine, replaces therouterservice that each project used to declare. Ports 80 and 443 are bound once, projects run side by side, and the router survives their restarts. - The router joins the network of each project on
docker:up, and leaves it beforedocker:downremoves it. Projects keep their own network and never share one, so two projects exposing a service under the same name no longer collide in the Docker DNS. - The router is no longer built from a Dockerfile: it runs the upstream
caddy-docker-proxyimage and receives its base Caddyfile as a compose config, so enabling it no longer depends on a project'svendor/directory being present. - The mkcert CA now lives in
~/.castor/docker/router/certs/instead of the project shared home directory, which the global router could not read.
Added¶
docker:router:status,docker:router:logsanddocker:router:restartdocker:router:enablejoins the networks of the projects that are already running, instead of routing nothing until their nextdocker:up
Removed¶
CaddyRouterService, and theroutercompose profile with it. The router is no longer registered incastor.phprouter:enableandrouter:disable, renamed todocker:router:enableanddocker:router:disable
Upgrading¶
The per-project router of a previous version may still hold ports 80 and 443.
It is no longer declared in the generated compose file, so docker:up removes
it as an orphan — run it before enabling the global router:
castor docker:up # drops the old per-project router container
castor docker:router:enable # starts the global one
Should a container still hold those ports, remove it with
docker rm -f <name>.
0.1.3 - 2026-07-27¶
Fixed¶
- Remove certificates before writing them when router is reenabled : they may be readonly and copy on a existing readonly file will fail
- Set complete versions for databases, has some dependencies expect a complete version
0.1.2 - 2026-07-25¶
Fixed¶
- Create the host directories bind-mounted by the services — the shared home
directory, the application directories, any other mount inside the project —
before docker does. Docker creates a missing bind mount source as
root, which then leaves the containers unable to write in it. A directory left over from an earlier run and not writable is reported instead.
0.1.1 - 2026-07-25¶
Fixed¶
- Create
compose.yamlon a project that declares no#[AsContext]function. Castor only dispatchesContextCreatedEventwhen it instantiates a declared context, so a fresh project never got its compose file and everydocker:*task failed on the missing file. - Stop
castor listfrom regeneratingcompose.generated.yamlfrom a bare context, which dropped the project configuration: the services exposing a UI fell back to the default root domain, and the containers to the default user id.
0.1.0 - 2026-07-25¶
First release.
Services¶
- PHP and Symfony applications, served by FrankenPHP or nginx + PHP-FPM, with a builder container, background workers, FrankenPHP worker mode and QA tasks (PHPStan, PHP CS Fixer, Rector, Twig CS Fixer)
- Go and Rust applications, built and run from the mounted sources, with a watch task rebuilding on change
- PostgreSQL, MySQL, MariaDB and ClickHouse, linkable to an application with
withDatabaseService() - Redis, RabbitMQ, Elasticsearch and Mailpit
- redirection.io agent (v3), running as a reverse proxy in front of the applications
- Caddy router, building its routes from the Docker labels of the services and serving HTTPS with on-demand, locally-trusted certificates
Tasks¶
docker:build,docker:up,docker:stop,docker:logs,docker:ps,docker:destroyanddocker:pushdocker:service:installanddocker:service:remove, registering a service in yourcastor.phpwith a format-preserving AST rewrite{service}:expose, forwarding a TCP service to the host and remembering it across restarts- One task set per registered service
Notes¶
- Services are configured with fluent
with*()methods, provided by the behaviour traits inCastor\Docker\Service\Behaviour - The Dockerfiles shipped by the plugin are rendered by
twig-dockerfile, pinned to
0.1 - Documentation: https://castor-php.github.io/docker/