[{"content":"The composer.json in each service had this in its post-install-cmd section:\n\u0026#34;post-install-cmd\u0026#34;: [ \u0026#34;bin/console cache:clear --env=prod\u0026#34;, \u0026#34;bin/console doctrine:migrations:migrate --no-interaction\u0026#34; ] post-install-cmd runs during composer install, which in the production Dockerfile runs during the image build. There is no database available during a Docker build. The migration command either failed silently, or connected to nothing, or was skipped by Doctrine when it couldn\u0026rsquo;t find a schema to compare against. In any case, it didn\u0026rsquo;t migrate anything.\nThis is a clean violation of Factor XII : admin processes — migrations, one-off scripts, console tasks — should run in the same environment as the application, against the actual production data. Running them at build time inverts the relationship. The image shouldn\u0026rsquo;t know about the database. The database should be there when the image needs it.\nThe move to the entrypoint The migration command moved from composer.json to docker-entrypoint.sh. The shift looks small on a diff. The implications are not.\nThe entrypoint runs when the container starts, not when the image is built. The database is reachable. The entrypoint waits for it — up to 60 seconds, one attempt per second — before doing anything:\nATTEMPTS_LEFT_TO_REACH_DATABASE=60 until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || \\ DATABASE_ERROR=$(php bin/console dbal:run-sql -q \u0026#34;SELECT 1\u0026#34; 2\u0026gt;\u0026amp;1); do sleep 1 ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1)) done if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then echo \u0026#34;$DATABASE_ERROR\u0026#34; exit 1 fi If the database doesn\u0026rsquo;t respond within 60 seconds, the container exits with an error and Kubernetes restarts it. Once the database is ready, the migration runs:\nif [ \u0026#34;$( find ./migrations -iname \u0026#39;*.php\u0026#39; -print -quit )\u0026#34; ]; then php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing fi Two changes from the original command: --all-or-nothing ensures that if any migration in a batch fails, the entire batch rolls back. And the find guard skips the command entirely if there are no migration files — useful for services that don\u0026rsquo;t use Doctrine migrations at all.\nThis is genuinely better. The database is present. The migration runs in the real environment. The --all-or-nothing flag adds atomicity that the build-time version never had.\nWhat it doesn\u0026rsquo;t solve Two pods redeploying simultaneously both run the entrypoint. Both reach the database. Both find pending migrations. Both call doctrine:migrations:migrate.\nDoctrine has a locking mechanism: a doctrine_migration_versions table that records which migrations have run, and the command checks it before applying. Under normal conditions this is fine: the second pod finds the table up to date and exits cleanly. The real failure modes are more specific: a migration long enough that the database lock times out before it completes, letting a second runner start the same migration before the first has finished; or a pod that crashes mid-migration before recording the version in the table, leaving the schema in an applied-but-unregistered state that the next pod will try to apply again.\nThe team\u0026rsquo;s position is explicit: a brief deployment downtime is acceptable. Application versions aren\u0026rsquo;t necessarily forward-compatible with older schema versions, so running N and N+1 simultaneously against the same database isn\u0026rsquo;t safe anyway. The deployment strategy is Recreate: all old pods are terminated before any new pods start. The migration runs on first startup, no overlap between versions. It works.\nBut \u0026ldquo;it works\u0026rdquo; and \u0026ldquo;it\u0026rsquo;s the right architecture\u0026rdquo; are different answers.\nWhat would be different Factor XII says admin processes should run in \u0026ldquo;one-off processes.\u0026rdquo; A process that runs once, for a specific purpose, against the production environment. The entrypoint is not one-off — it runs every time a container starts, including restarts, scaling events, and Kubernetes node movements.\nThree alternatives exist, each with a different answer to the question of ownership:\nA Kubernetes init container runs before the main container starts, in the same pod. It could run the migration, exit, and let the main container start only after it succeeds. The migration is isolated from the application runtime. The downside: the init container is another image to build and maintain, and it runs on every pod start — so a 14-service platform starting simultaneously still has a potential race.\nA Kubernetes Job runs once, on demand or triggered by a deployment pipeline. It can be made to run before any pods are updated — serial, isolated, with a clear success or failure signal. The race condition goes away. The complexity moves to the deployment process: the Job must complete before the Deployment rollout begins, and the CI pipeline must coordinate both.\nA Helm hook is the same concept expressed declaratively in the Helm chart. A pre-upgrade hook runs the migration before the application pods are updated. It\u0026rsquo;s the most idiomatic Kubernetes answer. It also means the Helm chart is now responsible for running migrations — a decision that belongs to whoever owns the chart.\nThat last sentence is why the entrypoint hasn\u0026rsquo;t changed. Moving migrations out of the application means deciding that the deployment infrastructure — not the application itself — is responsible for the schema. It\u0026rsquo;s a governance question as much as a technical one, and governance questions take longer to resolve than code changes.\nThe honest end The migration block in the entrypoint is two lines. Literally: the if [ \u0026quot;$( find ./migrations... )\u0026quot; ] guard, and the php bin/console doctrine:migrations:migrate that follows. Eleven other factors have clean resolutions. The cache moved to Redis. The logs go to stdout. The filesystem is an S3 bucket. The CI assembles production images from the same commit it tests. The secrets don\u0026rsquo;t travel in image layers.\nFactor XII has an answer. It\u0026rsquo;s just not the final one.\nThe migrations run at startup, with a real database, with atomicity, with a bounded retry window. That\u0026rsquo;s better than running at build time against nothing. Whether they eventually move to a Job or a Helm hook is a conversation about who owns the schema — a question that a kubectl apply can\u0026rsquo;t answer.\n","permalink":"https://guillaumedelre.github.io/2026/05/17/eleven-out-of-twelve/","summary":"\u003cp\u003eThe \u003ccode\u003ecomposer.json\u003c/code\u003e in each service had this in its \u003ccode\u003epost-install-cmd\u003c/code\u003e section:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-json\" data-lang=\"json\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;post-install-cmd\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#960050;background-color:#1e0010\"\u003e:\u003c/span\u003e [\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;bin/console cache:clear --env=prod\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;bin/console doctrine:migrations:migrate --no-interaction\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003epost-install-cmd\u003c/code\u003e runs during \u003ccode\u003ecomposer install\u003c/code\u003e, which in the production Dockerfile runs during the image build. There is no database available during a Docker build. The migration command either failed silently, or connected to nothing, or was skipped by Doctrine when it couldn\u0026rsquo;t find a schema to compare against. In any case, it didn\u0026rsquo;t migrate anything.\u003c/p\u003e","title":"Eleven Out of Twelve"},{"content":"The rolling deploy looked clean. A new pod started. Kubernetes saw the healthcheck pass — php -v returned zero — and began routing traffic to the new container.\nFor the next forty seconds — out of a possible sixty — that container was polling for the database.\nRequests that landed on it during that window got errors. Not many — the window was short — but enough to show up as noise in the monitoring. The kind of noise that gets dismissed as a transient network issue and filed nowhere. The deploy succeeded. The pod eventually became ready. The mechanism that caused it was still there, waiting for the next deploy.\nThe entrypoint script does five things before FrankenPHP starts: copy a version file, verify the vendor directory, wait up to sixty seconds for the database, run pending migrations, install assets and set filesystem permissions. In Docker Compose, this is invisible. In Kubernetes, the gap becomes traffic.\nThe gap between started and ready Kubernetes decides whether to send traffic to a pod by watching its readiness probe. A pod whose readiness probe passes receives requests. A pod whose readiness probe fails is removed from the load balancer rotation until it recovers. This is the mechanism that makes rolling deploys safe: Kubernetes doesn\u0026rsquo;t cut over to a new pod until that pod says it\u0026rsquo;s ready.\nThe compose.yaml defines a healthcheck on every service:\nhealthcheck: test: [ \u0026#34;CMD\u0026#34;, \u0026#34;php\u0026#34;, \u0026#34;-v\u0026#34; ] interval: 30s timeout: 10s retries: 3 start_period: 10s php -v succeeds the moment the PHP binary is present — which is true from the first millisecond of container life. The start_period: 10s gives ten seconds before checks begin. But the entrypoint polling loop runs for up to sixty seconds before FrankenPHP even starts. At second ten, the healthcheck passes. The application is still waiting for the database.\nThe Dockerfile has a better signal:\nHEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1 Port 2019 is Caddy\u0026rsquo;s built-in metrics server, embedded directly in FrankenPHP. The endpoint is Prometheus-compatible and only responds once Caddy\u0026rsquo;s HTTP stack is fully initialized and PHP workers are accepting connections. php -v exits in fifty milliseconds regardless of what the application is doing — it checks the binary, not the server. :2019/metrics only answers when the server is actually serving. It is also not an endpoint added just for the probe: every service in the platform already has it scraped by Prometheus, so the signal is live regardless of any healthcheck configuration.\nThat\u0026rsquo;s closer. But in Kubernetes, the HEALTHCHECK instruction is ignored entirely. Kubernetes uses its own probe configuration. Without explicit probe definitions in the Kubernetes manifests, there are no readiness checks — and a pod is considered ready the moment its container starts.\nWhich means: pod starts, entrypoint begins polling, Kubernetes routes traffic, application is not yet serving. Requests arrive at a container that isn\u0026rsquo;t ready to handle them.\nThree signals, three questions Kubernetes separates container lifecycle into three distinct questions, each with its own probe type:\nstartupProbe — \u0026ldquo;Has the application finished starting?\u0026rdquo; Fires repeatedly until it passes, then hands off to liveness. Prevents the liveness probe from killing a container that\u0026rsquo;s legitimately slow to initialize. For a container whose entrypoint can take sixty seconds, this is the right tool.\nreadinessProbe — \u0026ldquo;Is the application ready to handle requests?\u0026rdquo; Fails and passes throughout the container\u0026rsquo;s life. When it fails, the pod is removed from the load balancer. This is what makes a rolling deploy safe.\nlivenessProbe — \u0026ldquo;Is the application still alive?\u0026rdquo; If it fails, Kubernetes restarts the container. Meant to catch hung processes, not slow startups.\nThe sixty-second polling loop belongs in the startupProbe\u0026rsquo;s patience, not in application code:\nstartupProbe: httpGet: path: /metrics port: 2019 failureThreshold: 12 # 12 attempts × 5s = 60s max periodSeconds: 5 Once the startupProbe passes, a readinessProbe on the same endpoint takes over — telling Kubernetes when the pod is safe to receive traffic — and a livenessProbe watches for hung processes. But the startupProbe is the one that absorbs the slow start. The entrypoint polling loop becomes redundant: its job was to keep the container alive while the database caught up. Without it, the application attempts to connect, fails, and the container exits — Kubernetes restarts the pod, and the startupProbe maintains its retry cycle until the database responds and the application starts cleanly. The retry responsibility moves from inside the entrypoint to the orchestrator, which is exactly where it belongs.\nThe migration problem The polling loop is the most visible issue, but the migrations create a subtler one.\nWith a rolling deploy and two replicas, Kubernetes starts a new pod while the old one still serves traffic. Both pods run the same entrypoint. Both reach doctrine:migrations:migrate.\nDoctrine\u0026rsquo;s migration table tracks which migrations have already executed, so a completed migration won\u0026rsquo;t run twice. But if two pods start simultaneously and both see a pending migration, both attempt to run it at the same time. Whether that\u0026rsquo;s safe depends on the migration: additive schema changes are usually fine; destructive ones less so. And you don\u0026rsquo;t get to choose which ones run on a deploy that didn\u0026rsquo;t expect to coordinate. --all-or-nothing wraps migrations in a transaction and rolls back everything if one fails — it\u0026rsquo;s about atomicity within a single run, not coordination across processes.\nThe cleaner approach separates the two concerns into two init containers: one that waits for the database, one that runs migrations. The main container starts only after both complete:\ninitContainers: - name: wait-for-db image: authentication:latest command: [\u0026#34;php\u0026#34;, \u0026#34;bin/console\u0026#34;, \u0026#34;dbal:run-sql\u0026#34;, \u0026#34;-q\u0026#34;, \u0026#34;SELECT 1\u0026#34;] - name: migrate image: authentication:latest command: [\u0026#34;php\u0026#34;, \u0026#34;bin/console\u0026#34;, \u0026#34;doctrine:migrations:migrate\u0026#34;, \u0026#34;--no-interaction\u0026#34;, \u0026#34;--all-or-nothing\u0026#34;] Both init containers reuse the application image. That\u0026rsquo;s not waste: they need the same PHP binary and the same environment wiring to reach the database and resolve the migration classes. A lighter purpose-built image would reduce startup overhead, but would require maintaining a separate PHP installation in sync with the main image.\nEven with init containers, multiple pods starting simultaneously — initial deploy, after a node failure, or under autoscaling pressure — will each attempt to run migrations. Solving that properly — through a Helm pre-upgrade hook, a maxSurge: 0 strategy, or a separate migration Job — is a topic in itself. What matters here is that the entrypoint is the wrong place to host that decision: it can\u0026rsquo;t coordinate across pods, and it ties migration execution to application startup in a way that\u0026rsquo;s hard to untangle later. The question of which approach fits this codebase — and why the entrypoint hasn\u0026rsquo;t been replaced — gets its own treatment in the next article in this series .\nFactor XII of the twelve-factor methodology — admin processes run in the same environment as the application — is satisfied either way. The question is whether \u0026ldquo;same environment\u0026rdquo; means \u0026ldquo;same entrypoint script\u0026rdquo; or \u0026ldquo;same image, separate process\u0026rdquo;. In Kubernetes, the latter is safer.\nWhat the entrypoint\u0026rsquo;s real job is Strip out the database wait (now a startupProbe or init container), the migrations (now an init container or Job), and the assets install (a build-time operation that belongs in the Dockerfile), and the entrypoint has one remaining job: start the application.\nexec docker-php-entrypoint \u0026#34;$@\u0026#34; Factor IX of the twelve-factor app asks for fast startup and graceful shutdown. A container whose startup takes sixty seconds because it\u0026rsquo;s waiting for external dependencies is not fast. It means rolling deploys are slow, recovery after a crash is slow, and horizontal scale-out creates a sixty-second gap before each new pod contributes.\nFast startup is not just a nice-to-have. It\u0026rsquo;s what makes the rest of the cloud model work. When a pod can start in seconds, the orchestrator can scale aggressively and recover quickly. When it takes a minute, you add headroom everywhere — longer probe timeouts, larger deployment windows, more conservative scaling policies — and the system becomes rigid.\nThe Docker Compose tax The entrypoint accumulates these responsibilities for a reason. In Docker Compose, there is no init container concept. There is no startupProbe. Services declare depends_on, but without health conditions, that\u0026rsquo;s just startup ordering — not readiness. The entrypoint fills the gap.\nThis is not a design flaw. It\u0026rsquo;s a reasonable adaptation to the constraints of Docker Compose. The script works. It handles edge cases (the database timeout, unrecoverable errors, missing migrations directory). Someone tested it.\nThe issue is the assumption that the same script works equally well in Kubernetes. It runs. The application eventually starts. But it bypasses the probe system that makes Kubernetes deployments reliable, and it puts migration responsibility in a place where coordination across pods is difficult to reason about.\nSeveral of the changes in this series — media storage , secrets in image layers , log handlers , service dependencies , CI environment parity , cache adapters — were changes to application code or configuration. This one is different. It requires the infrastructure to gain awareness of what \u0026ldquo;ready\u0026rdquo; means for this application, and it requires the entrypoint to give up responsibilities it currently owns.\nThat\u0026rsquo;s a harder conversation. But the startupProbe is waiting for it.\n","permalink":"https://guillaumedelre.github.io/2026/05/17/ready-is-not-the-same-as-started/","summary":"\u003cp\u003eThe rolling deploy looked clean. A new pod started. Kubernetes saw the healthcheck pass — \u003ccode\u003ephp -v\u003c/code\u003e returned zero — and began routing traffic to the new container.\u003c/p\u003e\n\u003cp\u003eFor the next forty seconds — out of a possible sixty — that container was polling for the database.\u003c/p\u003e\n\u003cp\u003eRequests that landed on it during that window got errors. Not many — the window was short — but enough to show up as noise in the monitoring. The kind of noise that gets dismissed as a transient network issue and filed nowhere. The deploy succeeded. The pod eventually became ready. The mechanism that caused it was still there, waiting for the next deploy.\u003c/p\u003e","title":"Ready Is Not the Same as Started"},{"content":"The first time we ran two replicas of the same Symfony service behind a load balancer, everything looked fine. Health checks passed. Traffic split cleanly. Response times were good.\nThen someone noticed the rate limiter was acting strange. Hit the API five times, get blocked. Hit it five more times on the next request, get through. Depending on which pod answered, you were a different person.\nThat was the cache talking. One config line, replicated across thirteen services, was blocking horizontal scaling entirely.\nOne config file, thirteen times We were preparing a platform of thirteen Symfony microservices to move to Kubernetes. The stack was already in good shape: FrankenPHP for the HTTP server, multi-stage Dockerfiles, a GitLab CI that pushed tagged images to a cloud registry. The pieces were there. We just needed to verify nothing would break when we started scaling pods horizontally.\nA good checklist for that kind of audit is the twelve-factor app methodology — twelve principles for building software that runs cleanly in cloud environments. Most factors were already covered without us doing anything deliberate about it.\nFactor VII (port binding) came for free. FrankenPHP embeds Caddy directly into the PHP process. The container exposes its own HTTP endpoint, no Apache or Nginx to bolt on. The image is self-contained, which is exactly what the factor requires:\nHEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1 Factor II (dependencies) was handled by composer.json and the Dockerfile extensions. Factor X (dev/prod parity) was covered enough for our scope: same image, same backing services locally and in CI, which is the part that actually matters for what we were auditing.\nThen I got to Factor VI.\nThe problem with \u0026ldquo;it works on one server\u0026rdquo; Factor VI says processes must share nothing. Nothing written to disk between requests, nothing in local memory that another instance can\u0026rsquo;t see. If you need to persist state, put it in a backing service — a database, a cache cluster, a queue. The process itself stays disposable.\nI opened authentication/config/packages/cache.yaml. Then content/config/packages/cache.yaml. Then media/config/packages/cache.yaml.\nframework: cache: app: cache.adapter.filesystem Thirteen services. Thirteen times, word for word.\nEvery instance of every service was writing its cache to the local filesystem. Which meant every pod had its own private cache, invisible to every other pod. When the load balancer sent a request to pod A, it got pod A\u0026rsquo;s cached version of reality. Pod B had built its own. They might have been generated at different times, from different source data, or one of them might not have been built yet at all.\nThe rate limiter was the most visible symptom because it had a counter. But the same divergence affected every piece of data we were caching: serializer metadata, route collections, Doctrine result caches. Two users sending identical requests could get different responses depending on which node happened to pick up the connection.\nRedis was already there This is the part that stings a little. Redis was already in the stack. Every service had it configured via SncRedisBundle:\n# config/packages/snc_redis.yaml — present on all 13 services snc_redis: clients: default: type: \u0026#39;phpredis\u0026#39; alias: \u0026#39;default\u0026#39; dsn: \u0026#39;%env(IN_MEM_STORE__URI)%\u0026#39; Factor IV of the twelve-factor app says backing services should be attached resources, interchangeable through configuration. Redis was exactly that: reachable via an environment variable, ready to be swapped for a managed instance in the cloud. The plumbing was done. We just weren\u0026rsquo;t using it for the application cache.\nSome services even had it right for specific pools. The rate limiter in the authentication service:\npools: rate_limiter.cache: adapter: cache.adapter.redis Which explains the inconsistency we saw first. The rate limit count went to Redis (shared across pods). The cache backing the rate limit check went to the filesystem (local to the pod). Two sources of truth, one invisible to the other.\nThe fix was one line per service:\nframework: cache: app: cache.adapter.redis default_redis_provider: snc_redis.default Thirteen files. Thirteen identical changes. The kind of fix that makes you feel like you should have caught it earlier, except it\u0026rsquo;s perfectly invisible when you\u0026rsquo;re running a single instance.\nWhat needs to move to Redis The filesystem cache violated Factor VI (processes carry local state they shouldn\u0026rsquo;t) and Factor VIII (you can\u0026rsquo;t scale out without sharing that state). They\u0026rsquo;re the same problem seen from two angles: VI describes what\u0026rsquo;s wrong, VIII describes what you can\u0026rsquo;t do because of it.\nWith a shared cache backend, a second pod is safe. The two pods build the same cache, see the same invalidations, agree on the same rate limits. You can add a third pod under load and remove it when traffic drops. The orchestrator handles it; the application doesn\u0026rsquo;t need to know.\nWithout it, horizontal scaling is a liability. More pods means more divergence, more \u0026ldquo;works on my machine\u0026rdquo; bugs that are impossible to reproduce locally because local only runs one container.\nSessions had the same problem — and potentially a worse one. Twelve of the thirteen services were using session.storage.factory.native — which writes sessions to the filesystem by default. A user whose request lands on pod A gets a session tied to pod A. Their next request goes to pod B. Session gone, they\u0026rsquo;re logged out. Only one service had RedisSessionHandler configured.\nThe partial mitigation is that most of the platform runs stateless JWT-based APIs, so session usage is limited. But \u0026ldquo;limited\u0026rdquo; isn\u0026rsquo;t \u0026ldquo;zero\u0026rdquo;. The services that do create sessions — authentication flows, temporary state during OAuth handshakes — have a user-visible failure mode waiting for the second pod. Either those sessions get moved to Redis, or the code that creates them gets removed. Leaving them as-is is a decision that waits for the first user whose session disappears without explanation.\nThe other kind of state Redis fixes the cross-pod problem. FrankenPHP introduces a different one worth knowing about.\nIn the standard PHP-FPM model, each request forks a fresh process. Every in-memory object — every cached value, every computed result — dies with the response. The process is stateless by construction.\nFrankenPHP has a worker mode that doesn\u0026rsquo;t follow that model. In worker mode, a single PHP process boots once, loads the kernel, wires the container, and handles multiple successive requests without restarting. Request throughput improves: no autoloader cold start, no container rebuild per request, fewer allocations. The tradeoff is that the PHP process now has a lifecycle that spans requests.\nFor cache, this adds a wrinkle. An array adapter or APCu pool accumulates entries across requests on the same worker. A cache invalidation pushed to Redis reaches the other pods immediately — but doesn\u0026rsquo;t clear what\u0026rsquo;s sitting in a worker\u0026rsquo;s in-process memory. Two requests on the same pod can see different things: one hits a warm in-memory entry, the next triggers a Redis fetch after the in-process entry expires.\nThe platform keeps worker mode disabled (APP__WORKER_MODE__ENABLED=false). It\u0026rsquo;s available — the infrastructure is there, the flag is wired — but it\u0026rsquo;s not active. The performance gain didn\u0026rsquo;t justify the audit. Every cache pool would need to be verified against worker-mode semantics; every place where state leaks between requests would become a potential bug.\nThe conservative position: keep PHP stateless at the process level even when the runtime doesn\u0026rsquo;t require it. Factor VI\u0026rsquo;s shared-nothing principle applies not just to the filesystem — it applies to the process itself.\nWhat was already working To be fair to the codebase: the Symfony Scheduler was already using Redis for distributed locks:\n$schedule-\u0026gt;lock($this-\u0026gt;lockFactory-\u0026gt;createLock(\u0026#39;schedule_purge\u0026#39;)); In a multi-pod environment, you don\u0026rsquo;t want five instances running the same purge job simultaneously. The lock prevents it. Redis makes the lock visible across pods. Whoever wrote the scheduler knew exactly what they were doing.\nThe same reasoning just hadn\u0026rsquo;t propagated to the cache configuration — probably because when you\u0026rsquo;re running a single instance, cache.adapter.filesystem is invisible. It works, it\u0026rsquo;s fast, it requires zero configuration. The problem only appears at two.\nThe four questions Factor VI catches most applications off guard during a cloud migration. Not because developers don\u0026rsquo;t know about stateless processes — they usually do — but because the filesystem is always there, and the problem stays hidden until you try to run a second instance.\nBefore scaling a Symfony service horizontally, four questions are worth answering:\nWhere does the application cache go? (cache.adapter.filesystem needs to become cache.adapter.redis) Where do sessions go? (session.storage.factory.native needs Redis — or remove sessions entirely if you\u0026rsquo;re JWT-only) Does anything write to var/ at runtime that another pod would need to read? Is anything in your code path that needs to be mutually exclusive across pods? (if yes, that\u0026rsquo;s a job for the Symfony Lock component backed by Redis, not a local mutex) If the answers all point to shared backing services, you\u0026rsquo;re ready. If any of them points to the local filesystem, production will find the pod that built its cache three hours ago and serve it to the user who least expects it.\n","permalink":"https://guillaumedelre.github.io/2026/05/16/the-cache-that-was-lying-to-us/","summary":"\u003cp\u003eThe first time we ran two replicas of the same Symfony service behind a load balancer, everything looked fine. Health checks passed. Traffic split cleanly. Response times were good.\u003c/p\u003e\n\u003cp\u003eThen someone noticed the rate limiter was acting strange. Hit the API five times, get blocked. Hit it five more times on the next request, get through. Depending on which pod answered, you were a different person.\u003c/p\u003e\n\u003cp\u003eThat was the cache talking. One config line, replicated across thirteen services, was blocking horizontal scaling entirely.\u003c/p\u003e","title":"The Cache That Was Lying to Us"},{"content":"The pipeline had two stages that had nothing to do with code: provision and deprovision. Between them, in sequence, came phpunit, phpmetrics, and behat.\nstages: - build - provision - phpunit - phpmetrics - behat - deprovision - deploy Before the first assertion ran, fifteen minutes had passed. Terraform had cloned an infrastructure repository, authenticated to Azure, and applied a VM configuration. Ansible had connected to the new VM, installed PHP, configured the application, wired up a database and a Redis instance. Then the tests ran. Then Terraform destroyed what Ansible had built.\nFor every pipeline. From every branch. For every pull request, from open to merge.\nWhat those fifteen minutes were missing The provision stage set up two services: PostgreSQL and Redis. Three services that the application depended on in production were absent: RabbitMQ, MinIO, and Varnish.\nRabbitMQ processed all asynchronous work — 56 consumers across 14 microservices. MinIO handled media storage. Varnish fronted the HTTP cache. In CI, none of them existed. Tests that exercised message queuing or file storage had two options: skip these paths, or leave them untested until staging. Varnish is a different case: tests hit the application directly and intentionally bypass the cache layer, so its absence in CI is a deliberate choice rather than a gap.\nThis is the problem Factor X describes as the environment gap. The gap here wasn\u0026rsquo;t a matter of configuration — it was structural. The VM was built by Ansible from a script in a separate repository. It wasn\u0026rsquo;t a container image. It wasn\u0026rsquo;t versioned alongside the application. If a branch modified the RabbitMQ message topology, there was no way to test that modification in CI. The topology change and the code that relied on it would only meet in staging.\nThe Ansible provisioning script itself is part of the problem:\nlaunch_vm: stage: provision script: - git clone git@gitlab.internal/infra/ci-vm.git - cd ci-vm - az login --service-principal -u $ARM_CLIENT_ID ... - terraform apply -var \u0026#34;prefix=${CI_PIPELINE_ID}-vm\u0026#34; ... - sleep 45 - ansible-playbook behat/test-env.yml ... The sleep 45 is there because Ansible needs the VM to finish booting before it can connect. It\u0026rsquo;s not an oversight — it\u0026rsquo;s the minimum time a freshly provisioned VM needs before SSH works. It\u0026rsquo;s baked into the process.\nWhat replaced it The new pipeline has no provision stage. It has no deprovision stage. The environment is the images, and the images exist before the tests begin.\nEach test job declares its dependencies as Docker services:\nservices: - name: $REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG alias: rabbitmq - name: $REGISTRY_URL/platform/minio:$CI_COMMIT_REF_SLUG alias: minio - name: redis:7.4.1 alias: redis - name: $ARTIFACTORY_URL/postgresql:13 alias: postgresql The services start in parallel when the job begins. Before the test script runs, a before_script waits for all of them to be ready:\nbefore_script: - $CI_PROJECT_DIR/dockerize -wait tcp://postgresql:5432 -wait tcp://rabbitmq:5672 -wait tcp://minio:9000 -wait tcp://redis:6379 -timeout 120s From pipeline start to first assertion: ninety seconds — assuming images are already cached on the runner; a cold pull adds time, but becomes negligible once the pipeline has run once on a given branch.\nWhat $CI_COMMIT_REF_SLUG means The timing is the visible result. What produces it is more interesting: the image names.\n$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG is not the official RabbitMQ image from Docker Hub. It\u0026rsquo;s an image built by the same pipeline, from the same branch, at the same commit as the code being tested. The RabbitMQ image carries the topology: a definitions.json with every exchange, every queue, every binding, every dead-letter configuration — versioned in git alongside the application that depends on them.\nIf a branch modifies the messaging topology, the CI pipeline builds a new RabbitMQ image that includes those modifications, then runs the tests against it. The topology change and the code that relies on it are tested together, at the same commit, before anything reaches staging.\nThe same logic applies to MinIO, as described in the first article in this series : the MinIO image carries preloaded test fixtures. The CI environment doesn\u0026rsquo;t need a setup step to populate storage. The state is built in.\nThe test runner itself follows the same pattern. Each job uses a debug variant of the application image — built from the same branch, same commit — with the test dependencies included:\nimage: $REGISTRY_URL/platform/$service:$CI_COMMIT_REF_SLUG-debug The whole environment assembles from artifacts built at the same point in the git history.\nWhat this required dropping Behat and the provisioned VM were coupled. The Behat test suite ran against an HTTP server on the VM; removing the VM meant removing Behat.\nThat turned out not to be the obstacle it looked like. The Behat suite lived in a separate repository, required the VM to run, and had accumulated significant maintenance overhead. PHPUnit, running inside the application container with Docker services, covered the same scenarios through a more direct path: functional tests exercising the HTTP layer, unit tests for individual components, suites organized per feature area and generated dynamically into parallel CI jobs.\nThe BDD layer went away. The test coverage stayed — and could now run against the actual services.\nFactor X, applied Factor X is often read as \u0026ldquo;use the same database locally as in production.\u0026rdquo; That\u0026rsquo;s the simplest version. The deeper version is about the gap between what you test and what you ship.\nThe gap in the old pipeline was wide: a manually configured VM, missing key services, rebuilt from scratch on every run. The gap in the new pipeline is narrow: the CI assembles the environment from the same images as production, built from the same commit as the code under test.\nThe fifteen minutes of Terraform and Ansible were not just slow. They were building something that wasn\u0026rsquo;t what production ran, every time, before any test could begin. The ninety seconds of docker pull build exactly what production runs — and the tests that follow are testing that, not an approximation of it.\n","permalink":"https://guillaumedelre.github.io/2026/05/16/fifteen-minutes-before-the-first-test/","summary":"\u003cp\u003eThe pipeline had two stages that had nothing to do with code: \u003ccode\u003eprovision\u003c/code\u003e and \u003ccode\u003edeprovision\u003c/code\u003e. Between them, in sequence, came \u003ccode\u003ephpunit\u003c/code\u003e, \u003ccode\u003ephpmetrics\u003c/code\u003e, and \u003ccode\u003ebehat\u003c/code\u003e.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003estages\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ebuild\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003eprovision\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ephpunit\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ephpmetrics\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ebehat\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003edeprovision\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003edeploy\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eBefore the first assertion ran, fifteen minutes had passed. Terraform had cloned an infrastructure repository, authenticated to Azure, and applied a VM configuration. Ansible had connected to the new VM, installed PHP, configured the application, wired up a database and a Redis instance. Then the tests ran. Then Terraform destroyed what Ansible had built.\u003c/p\u003e","title":"Fifteen Minutes Before the First Test"},{"content":"Every service in the platform had these six variables:\nAPP__GATEWAY__PRIVATE__HOST=\u0026#34;platform.internal\u0026#34; APP__GATEWAY__PRIVATE__PORT=80 APP__GATEWAY__PRIVATE__SCHEME=\u0026#34;http\u0026#34; APP__GATEWAY__PUBLIC__HOST=\u0026#34;platform.internal\u0026#34; APP__GATEWAY__PUBLIC__PORT=80 APP__GATEWAY__PUBLIC__SCHEME=\u0026#34;http\u0026#34; Thirteen services, six variables each, one value. Reading any service\u0026rsquo;s configuration, the architecture looked flat. Everything talked to the same host. That was the whole picture.\nIt wasn\u0026rsquo;t.\nHow the gateway worked The gateway sat in front of every service and handled all inter-service traffic. A service calling the content API would construct a request to http://platform.internal/content/api/ — the gateway received it, identified the target from the URL path, and forwarded it to the right backend. Every inter-service HTTP client in framework.yaml followed the same pattern:\ncontent.client: base_uri: \u0026#34;%http_client.gateway.base_uri%/content/api/\u0026#34; headers: Host: \u0026#34;%env(APP__GATEWAY__PRIVATE__HOST)%\u0026#34; The http_client.gateway.base_uri parameter was assembled from the GATEWAY vars. The gateway knew where each service ran. The services didn\u0026rsquo;t need to know. From their perspective, everything was platform.internal.\nThis worked. For years, it worked well. Adding a service meant adding one DNS alias in the gateway config, not touching thirteen .env files. The gateway abstracted the topology. The services stayed decoupled from the infrastructure detail of who ran where.\nWhat the gateway was absorbing The abstraction had a cost that didn\u0026rsquo;t show up until you tried to read the system.\nLooking at content\u0026rsquo;s env file, you saw six gateway variables and nothing else about inter-service communication. To find out that content called conversion, shorty, and media, you had to read framework.yaml. To find out that pilot called ten external services, you had to trace through the HTTP clients one by one and count.\nThe number was ten. Authentication, bam, config, content, conversion, media, product, shorty, sitemap, social. Ten of the platform\u0026rsquo;s thirteen services that pilot depended on at runtime, none of them visible from its configuration. Six variables said: talk to the gateway. They said nothing about the shape of what lay behind it.\nThat information existed — in the code, in the framework config, in the heads of the people who had built those integrations. It just didn\u0026rsquo;t live anywhere you could read at a glance.\nWhat Kubernetes made explicit On-premise, the gateway was a single resolvable hostname. One DNS record, one set of variables, one place to update. Kubernetes doesn\u0026rsquo;t work that way. Each service gets its own DNS name inside the cluster — content.namespace.svc.cluster.local, conversion.namespace.svc.cluster.local. Inter-service traffic goes directly, service to service, not through a shared gateway.\nMoving to Kubernetes meant the gateway abstraction had to give way. Each service needed to know, concretely, where each of its dependencies lived. The six generic variables couldn\u0026rsquo;t express that.\nThe refactor replaced them with per-target HOST variables — one per service dependency, named for the target:\n# content/.env — content calls these four services APP__CONFIG__HOST=\u0026#34;platform.internal\u0026#34; APP__CONVERSION__HOST=\u0026#34;platform.internal\u0026#34; APP__MEDIA__HOST=\u0026#34;platform.internal\u0026#34; APP__SHORTY__HOST=\u0026#34;platform.internal\u0026#34; # pilot/.env — ten service dependencies APP__AUTHENTICATION__HOST=\u0026#34;platform.internal\u0026#34; APP__BAM__HOST=\u0026#34;platform.internal\u0026#34; APP__CONFIG__HOST=\u0026#34;platform.internal\u0026#34; APP__CONTENT__HOST=\u0026#34;platform.internal\u0026#34; APP__CONVERSION__HOST=\u0026#34;platform.internal\u0026#34; APP__MEDIA__HOST=\u0026#34;platform.internal\u0026#34; APP__PRODUCT__HOST=\u0026#34;platform.internal\u0026#34; APP__SHORTY__HOST=\u0026#34;platform.internal\u0026#34; APP__SITEMAP__HOST=\u0026#34;platform.internal\u0026#34; APP__SOCIAL__HOST=\u0026#34;platform.internal\u0026#34; Each HTTP client in framework.yaml got its own base_uri built from its target\u0026rsquo;s HOST variable, and the Host header gave way to a User-Agent that identified the caller:\ncontent.client: base_uri: \u0026#34;%env(APP__HTTP__SCHEME)%://%env(APP__CONTENT__HOST)%:%env(APP__HTTP__PORT)%/content/api/\u0026#34; headers: User-Agent: \u0026#34;Platform Content - %semver%\u0026#34; The change isn\u0026rsquo;t cosmetic. In the old setup, the explicit Host header ensured requests reached the correct gateway virtual host regardless of URL resolution. In the new setup, each client points directly at its target\u0026rsquo;s DNS name — the right Host is derived from the base_uri automatically. The header slot doesn\u0026rsquo;t go empty: User-Agent now identifies the calling service, which surfaces in logs and distributed traces without any additional instrumentation.\nThe discomfort of legibility pilot\u0026rsquo;s env file went from nine gateway variables to ten service-specific HOST variables. The file got longer. The architecture didn\u0026rsquo;t get simpler — the ten dependencies were there before and they\u0026rsquo;re still there now. What changed is that they\u0026rsquo;re readable.\nFactor III says to store configuration in the environment. The old approach satisfied that literally: six variables, all in env files, none hardcoded. But variables that collapse the entire dependency graph into a single opaque hostname aren\u0026rsquo;t really configuration — they\u0026rsquo;re a shorthand that trades legibility for convenience. Factor III doesn\u0026rsquo;t ask only that configuration be externalized — it implicitly assumes the externalized configuration remains informative.\nThe refactor didn\u0026rsquo;t simplify anything. It made the complexity visible. pilot\u0026rsquo;s ten HOST variables document, in the .env file itself, the ten services it depends on. A new team member reading that file learns something real about the architecture. The old file taught them that there was a gateway.\nThere\u0026rsquo;s a version of this story where you read the final state and conclude the team did unnecessary work — they replaced six variables with ten, all pointing at the same host anyway. In local development, platform.internal still resolves to the same place. The functional behavior didn\u0026rsquo;t change.\nThe change is in what the configuration communicates. In Kubernetes, the HOST values diverge: each target gets its own cluster-internal DNS name, different per environment. The variables now carry real information. The refactor prepared the config to be honest about a topology it had been quietly simplifying for years.\n","permalink":"https://guillaumedelre.github.io/2026/05/15/the-host-that-hid-the-graph/","summary":"\u003cp\u003eEvery service in the platform had these six variables:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PRIVATE__HOST\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;platform.internal\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PRIVATE__PORT\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e80\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PRIVATE__SCHEME\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;http\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PUBLIC__HOST\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;platform.internal\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PUBLIC__PORT\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e80\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eAPP__GATEWAY__PUBLIC__SCHEME\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;http\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThirteen services, six variables each, one value. Reading any service\u0026rsquo;s configuration, the architecture looked flat. Everything talked to the same host. That was the whole picture.\u003c/p\u003e\n\u003cp\u003eIt wasn\u0026rsquo;t.\u003c/p\u003e\n\u003ch2 id=\"how-the-gateway-worked\"\u003eHow the gateway worked\u003c/h2\u003e\n\u003cp\u003eThe gateway sat in front of every service and handled all inter-service traffic. A service calling the content API would construct a request to \u003ccode\u003ehttp://platform.internal/content/api/\u003c/code\u003e — the gateway received it, identified the target from the URL path, and forwarded it to the right backend. Every inter-service HTTP client in \u003ccode\u003eframework.yaml\u003c/code\u003e followed the same pattern:\u003c/p\u003e","title":"The Host That Hid the Graph"},{"content":"The service had crashed. We had the alert. We had the timestamp down to the second. We had Loki open and a query ready.\nWhat we didn\u0026rsquo;t have was any logs from the five minutes before the crash.\nPromtail was running. It was healthy. It had been collecting logs from every other service without issue. But for this one, in the window that mattered, there was nothing. The service had crashed without leaving a trace.\nThe setup that looked correct The logging stack was reasonable. Each service wrote structured JSON to stdout using Monolog\u0026rsquo;s logstash formatter:\nstdout: type: stream path: \u0026#34;php://stdout\u0026#34; level: \u0026#34;%env(MONOLOG_LEVEL__DEFAULT)%\u0026#34; formatter: \u0026#39;monolog.formatter.logstash\u0026#39; Promtail collected container output via the Docker socket, parsed the JSON, extracted labels, pushed to Loki:\nscrape_configs: - job_name: docker docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s pipeline_stages: - drop: older_than: 168h - json: expressions: level: level msg: message service: service - labels: level: service: relabel_configs: - source_labels: [ \u0026#39;__meta_docker_container_log_stream\u0026#39; ] target_label: stream Two stages in that pipeline do more work than the others. The json stage extracts level and service from each log line; the labels stage immediately following promotes them to Loki index labels, making {service=\u0026quot;content\u0026quot;, level=\u0026quot;error\u0026quot;} a direct index lookup rather than a full-text scan across stored lines. The stream relabeling preserves whether a line came from stdout or stderr — a distinction that becomes queryable once Monolog sends errors to stderr and everything else to stdout. The drop older_than: 168h stage is a safety valve: if Promtail restarts after a long gap and replays buffered lines, anything older than seven days is discarded before reaching Loki.\nIn theory: logs go to stdout, Promtail reads stdout, logs appear in Loki. The twelve-factor app methodology describes exactly this model for Factor XI — treat logs as event streams, write to stdout, let the environment handle collection and routing.\nThe application had stdout. Promtail was reading stdout. What could go wrong.\nWhat fingers_crossed takes with it In production, the when@prod block replaced the simple stream handler with something more sophisticated:\nwhen@prod: monolog: handlers: main: type: fingers_crossed action_level: error handler: main_group excluded_http_codes: [404] The excluded_http_codes: [404] line is itself a tell: without it, every 404 from a scanner or crawler triggers a full buffer flush, dumping megabytes of debug logs for malformed URLs. Someone had already learned that the hard way.\nfingers_crossed is a well-known Monolog pattern. The idea is elegant: don\u0026rsquo;t flood production logs with debug noise, but if something goes wrong, retroactively show what happened before the error. The handler buffers every log record in memory. The moment it sees an error, it flushes the entire buffer to the nested handler — giving you the full context leading up to the failure.\nThe problem is what happens when the failure isn\u0026rsquo;t a logged error. It\u0026rsquo;s an OOM kill. A SIGKILL from the orchestrator. A segfault. A process that stops responding and gets forcibly terminated.\nIn those cases, fingers_crossed never reaches its action_level. The buffer exists, full of the last five minutes of activity, and it vanishes with the process. The logs were there. They were in memory. They died before reaching stdout.\nFactor IX of the twelve-factor app talks about disposability: processes should start fast and stop gracefully. On a clean shutdown (SIGTERM), a well-behaved process finishes its current work and exits. But crashes are not clean shutdowns, and memory buffers are not crash-safe. The service had been disposable in the sense that we could restart it; it was not disposable in the sense that its exit was transparent.\nThe files nobody was reading There was a second problem, quieter but just as persistent.\nEvery service had a main_group handler that routed logs to two destinations in parallel:\nmain_group: type: group members: [main_file, stdout] main_file: type: stream path: \u0026#34;%kernel.logs_dir%/%kernel.environment%.log\u0026#34; formatter: \u0026#34;monolog.formatter.logstash\u0026#34; var/log/prod.log was being written on every service, in every environment, including production. The same content that went to stdout also went to a file inside the container. The file grew without rotation. The file was not accessible to Promtail (which read from the Docker socket, not from the container filesystem). The file consumed disk space. Nobody was reading it.\nThe audit channel was worse:\naudit_file: type: stream path: \u0026#34;%kernel.logs_dir%/audit.log\u0026#34; formatter: \u0026#39;monolog.formatter.line\u0026#39; audit: type: group members: [audit_file, stderr] channels: [\u0026#39;audit\u0026#39;] Audit logs went to stderr (visible to Promtail) and to audit.log (not visible to Promtail). The format in the file was a plain line format, not the structured JSON that Promtail expected. In practice, the audit trail existed in two places: one queryable, one buried in a container directory that survived only as long as the container did.\nWhat Factor XI actually requires The eleventh factor is direct about this: an app should not concern itself with routing or storage of its output stream. It writes to stdout. Everything else is the environment\u0026rsquo;s job.\nThat means no file handlers in production. Not as a backup. Not for audit trails. Not \u0026ldquo;just in case\u0026rdquo;. The moment an application starts managing files, it takes on responsibility for rotation, retention, disk space, and accessibility — none of which belong inside a container.\nThe fix for the file handlers is straightforward. In when@prod, remove every *_file handler and every group that includes one. The audit channel gets the same treatment: stderr only, structured JSON, no file:\nwhen@prod: monolog: handlers: stdout: type: stream path: \u0026#34;php://stdout\u0026#34; # defaults to \u0026#34;warning\u0026#34; — overridable per-deploy via env var for targeted debugging level: \u0026#34;%env(default:default_log_level:MONOLOG_LEVEL__DEFAULT)%\u0026#34; formatter: \u0026#39;monolog.formatter.logstash\u0026#39; stderr: type: stream path: \u0026#34;php://stderr\u0026#34; level: error formatter: \u0026#39;monolog.formatter.logstash\u0026#39; main: type: group members: [stdout] channels: [\u0026#39;!event\u0026#39;, \u0026#39;!http_client\u0026#39;, \u0026#39;!doctrine\u0026#39;, \u0026#39;!deprecation\u0026#39;, \u0026#39;!audit\u0026#39;] audit: type: stream path: \u0026#34;php://stderr\u0026#34; level: debug formatter: \u0026#39;monolog.formatter.logstash\u0026#39; channels: [\u0026#39;audit\u0026#39;] stdout for the main channel. stderr for errors and audit. Nothing else. Promtail picks up both via the Docker socket. The container writes nothing to disk. And audit logs are now structured JSON, queryable in Loki alongside everything else.\nThe harder question about fingers_crossed The file handlers were easy. fingers_crossed is more nuanced.\nThe pattern solves a real problem: in a busy production service, logging everything at debug level creates noise and cost. fingers_crossed lets you capture context without paying for it unless something actually goes wrong. It is a reasonable tradeoff when the failure mode you\u0026rsquo;re protecting against is an application-level error (an exception, a 500, a slow query).\nIt is not a reasonable tradeoff when the failure mode is a process crash. And in a Kubernetes environment, process crashes happen: OOM evictions, liveness probe failures, node pressure. Exactly the cases where you most need the logs.\nOne approach: keep fingers_crossed but reduce the buffer size. By default it keeps everything since the last reset. Set buffer_size: 50 and you cap memory usage, which also limits what gets lost on crash. You won\u0026rsquo;t have the full context, but you\u0026rsquo;ll have the last fifty records. This patches the blast radius rather than removing the root cause: the opacity still depends on an error threshold that may never fire.\nAnother approach: accept that debug logs are expensive and raise the default log level in production. Then you don\u0026rsquo;t need fingers_crossed at all — if info and above go directly to stdout, nothing is ever buffered.\nThe approach we landed on: drop fingers_crossed, raise the default level to warning, keep a debug override available via env var for targeted investigation. The logs we care about appear immediately. The ones we don\u0026rsquo;t are never written. Nothing is buffered.\nCrashes don\u0026rsquo;t flush Factor XI and Factor IX meet at the same point: a process dying mid-request. another article in this series described the illusion of a service that worked perfectly on one pod but quietly misbehaved on two. This is the same illusion, one layer up: a service that appeared to log correctly, until the moment it most needed to.\nThe rule for production Monolog is blunt: if it doesn\u0026rsquo;t reach stdout or stderr before the process exits, it doesn\u0026rsquo;t exist. A file handler inside a container is invisible to the log collector and dies with the pod. A fingers_crossed buffer is invisible to the log collector and dies with the process.\nProduction tends to create the conditions where you need logs the most — OOM pressure, cascading failures, bad deploys — and those are exactly the conditions where both of these patterns fail you simultaneously. Write to stdout, default to a level that doesn\u0026rsquo;t require buffering, and make the override available for when you actually need to debug something. The logs will be there. They won\u0026rsquo;t be waiting for an error threshold that never fires.\n","permalink":"https://guillaumedelre.github.io/2026/05/15/no-witnesses/","summary":"\u003cp\u003eThe service had crashed. We had the alert. We had the timestamp down to the second. We had \u003ca href=\"https://grafana.com/oss/loki/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eLoki\u003c/a\u003e open and a query ready.\u003c/p\u003e\n\u003cp\u003eWhat we didn\u0026rsquo;t have was any logs from the five minutes before the crash.\u003c/p\u003e\n\u003cp\u003ePromtail was running. It was healthy. It had been collecting logs from every other service without issue. But for this one, in the window that mattered, there was nothing. The service had crashed without leaving a trace.\u003c/p\u003e","title":"No Witnesses"},{"content":"At some point during a cloud migration audit, someone ran this:\ndocker run --rm \u0026lt;image\u0026gt; php -r \u0026#34;var_dump(require \u0026#39;.env.local.php\u0026#39;);\u0026#34; The output showed everything that composer dump-env prod had compiled into the image at build time. Which meant it showed everything that had been in the .env file when the image was built. Which meant it showed these, among others:\nINFLUXDB_INIT_ADMIN_TOKEN=\u0026lt;influxdb-admin-token\u0026gt; GF_SECURITY_ADMIN_USER=admin GF_SECURITY_ADMIN_PASSWORD=admin123 BLACKFIRE_CLIENT_ID=\u0026lt;blackfire-client-id\u0026gt; BLACKFIRE_CLIENT_TOKEN=\u0026lt;blackfire-client-token\u0026gt; BLACKFIRE_SERVER_ID=\u0026lt;blackfire-server-id\u0026gt; BLACKFIRE_SERVER_TOKEN=\u0026lt;blackfire-server-token\u0026gt; NGROK_AUTHTOKEN=replace-me-optionnal Twenty-five variables in total. Every credential that had accumulated in the root .env over three years, now permanent in an image layer.\nHow dump-env works composer dump-env prod is a legitimate Symfony optimization. Instead of parsing .env files on every request, the runtime loads a pre-compiled PHP array from .env.local.php. Faster and simpler.\nThe problem is what it reads. The Dockerfile copies the repository into the image with COPY . ./, .env included. Then dump-env prod reads that file and compiles every variable into .env.local.php. The image ships with a frozen snapshot of the credentials that were in .env at build time.\nDocker layers are immutable archives. Even if a subsequent step removed .env from the container filesystem, the layer containing it would still exist inside the image. docker save \u0026lt;image\u0026gt; produces a tarball of every layer; extracting any file from any point in the build history is straightforward. The credentials are invisible at runtime. They are not gone.\nFactor V calls this out directly: a build artifact should be environment-agnostic, with config arriving at the release step from outside. Once credentials are compiled in, the image is no longer portable. You can\u0026rsquo;t promote it across environments. You build twice and hope the second build behaves like the first.\nHow twenty-five variables accumulate Before tracing how this gets fixed, it\u0026rsquo;s worth understanding how it happened.\nThe BLACKFIRE_* tokens are the easy case to understand. A team member sets up profiling, needs to share the configuration, and the repository is already open to everyone. One line in .env is the path of least resistance. The InfluxDB and Grafana credentials follow the same logic — shared tooling, shared repo, one commit.\nThen there are the variables that reveal a different kind of drift. In some of the service-level .env files:\nAPP__RATINGS__SERIALS=\u0026#39;{\u0026#34;brand1\u0026#34;:{\u0026#34;fr\u0026#34;:\u0026#34;12345\u0026#34;},...}\u0026#39; # ~40 lines of JSON APP__YOUTUBE__CREDENTIALS=\u0026#39;{\u0026#34;brand1\u0026#34;:{\u0026#34;client_id\u0026#34;:\u0026#34;xxx\u0026#34;,\u0026#34;refresh_token\u0026#34;:\u0026#34;yyy\u0026#34;},...}\u0026#39; Audience measurement serial numbers. YouTube API refresh tokens per brand. These aren\u0026rsquo;t secrets in the Blackfire sense. They\u0026rsquo;re business data — the kind of values that vary between brands and environments, that someone decided to version in .env because they behaved like configuration and .env was where configuration lived.\nTwenty-five variables is the sum of incremental decisions, none of which felt wrong in isolation. The problem is structural: when .env is the only answer available, everything starts looking like it belongs there.\nWhere things actually belong Emptying the file required answering one question for each variable: where does this actually belong?\nThe answers revealed three categories that the team had never explicitly named:\nStatic config lives in code. Business rules, routing logic, Symfony parameter files — anything that doesn\u0026rsquo;t vary between deployments. A change requires a rebuild. The JSON blobs for audience measurement serials turned out not to be static config at all: they were queried from a dedicated Config service at runtime. They had no business being in a file.\nEnvironment config varies between deployments: hostnames, connection strings, third-party credentials. This is what Factor III means by \u0026ldquo;config in environment variables\u0026rdquo; — real OS-level variables injected by the runtime, never files that travel with the code. In Kubernetes, this becomes a ConfigMap for non-sensitive values and a Kubernetes Secret for credentials. The choice for secrets management was SOPS — credentials are encrypted and committed to git, rather than stored in an external vault like Azure Key Vault or HashiCorp Vault. A vault trades simplicity for auditability: automatic rotation, centralized audit logs, workload identity-based access with no key to protect. SOPS trades those capabilities for a simpler operational model — no external service to query at deploy time, secrets travel through the normal code review process, git history serves as the audit trail. The accepted downsides are manual rotation and the responsibility of protecting the decryption key itself. For the team\u0026rsquo;s scale, the tradeoff was deliberate.\nDynamic config changes without a deployment: editorial parameters, per-brand thresholds, content moderation settings. It belongs in a database, managed through the application\u0026rsquo;s Config service. Some of what had accumulated in .env files was this category all along, passing as static defaults because it changed rarely enough that nobody noticed.\nOnce the categories had names, the variables sorted themselves. The root .env ended at four lines:\nDOMAIN=platform.127.0.0.1.sslip.io XDEBUG_MODE=off SERVER_NAME=:80 APP_ENV=dev Safe defaults. Nothing sensitive. dump-env prod now compiles empty strings; real values arrive at runtime from Kubernetes.\nThe PostgreSQL image The PostgreSQL image used in CI has a hardcoded password:\nFROM postgres:15 ENV POSTGRES_PASSWORD=admin123 This looks like the same problem. It isn\u0026rsquo;t, because the threat model is different. The CI database is ephemeral — it exists for the duration of a pipeline run, contains no real data, and runs in an isolated network. A hardcoded password on a throwaway test database is an acceptable risk, not a policy exception.\nIn production, the question doesn\u0026rsquo;t arise: the platform uses Azure Flexible Server, a managed PostgreSQL service. There is no Docker image. Credentials arrive via Helm chart injection, never touching a layer.\nWhat survives the build now The image that ships to production now contains a guarantee: var_dump(require '.env.local.php') returns only empty strings and safe defaults. The credentials aren\u0026rsquo;t there because they were never put there — they arrive at runtime, from outside.\nThat\u0026rsquo;s the responsibility boundary dump-env had been quietly erasing: the image is the application, the runtime is the environment. They should not know each other\u0026rsquo;s secrets.\n","permalink":"https://guillaumedelre.github.io/2026/05/14/what-survives-the-build/","summary":"\u003cp\u003eAt some point during a cloud migration audit, someone ran this:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker run --rm \u0026lt;image\u0026gt; php -r \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;var_dump(require \u0026#39;.env.local.php\u0026#39;);\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe output showed everything that \u003ccode\u003ecomposer dump-env prod\u003c/code\u003e had compiled into the image at build time. Which meant it showed everything that had been in the \u003ccode\u003e.env\u003c/code\u003e file when the image was built. Which meant it showed these, among others:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-dotenv\" data-lang=\"dotenv\"\u003eINFLUXDB_INIT_ADMIN_TOKEN=\u0026lt;influxdb-admin-token\u0026gt;\nGF_SECURITY_ADMIN_USER=admin\nGF_SECURITY_ADMIN_PASSWORD=admin123\nBLACKFIRE_CLIENT_ID=\u0026lt;blackfire-client-id\u0026gt;\nBLACKFIRE_CLIENT_TOKEN=\u0026lt;blackfire-client-token\u0026gt;\nBLACKFIRE_SERVER_ID=\u0026lt;blackfire-server-id\u0026gt;\nBLACKFIRE_SERVER_TOKEN=\u0026lt;blackfire-server-token\u0026gt;\nNGROK_AUTHTOKEN=replace-me-optionnal\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eTwenty-five variables in total. Every credential that had accumulated in the root \u003ccode\u003e.env\u003c/code\u003e over three years, now permanent in an image layer.\u003c/p\u003e","title":"What Survives the Build"},{"content":"APP__COLD_STORAGE__FILESYSTEM_PATH=\u0026#34;/home/jenkins-slave/share_media/media\u0026#34; APP__COLD_STORAGE__FILESYSTEM_PATH_CACHE=\u0026#34;/home/jenkins-slave/share_media/media/cache\u0026#34; APP__COLD_STORAGE__RAW_IMAGE_PATH=\u0026#34;/home/jenkins-slave/share_media/media_raw\u0026#34; APP__SHARE_STORAGE__FILESYSTEM_PATH=\u0026#34;/home/jenkins-slave/share_storage\u0026#34; These lines were in the production .env of the media service. Not staging. Not a local override. Production, committed to the repository, read on every startup.\nThe paths end where you\u0026rsquo;d expect: /media, /share_storage. They start somewhere more surprising: /home/jenkins-slave, the home directory of a CI runner from an old Jenkins setup.\nHow a runner\u0026rsquo;s home directory ends up in production config The platform had grown from a single machine. One server ran everything — the application, the CI runner, the database, the file storage. Files moved between the app and the CI system via NFS: a directory mounted on the same host, accessible to both the containers and the runner.\nThe path /home/jenkins-slave/share_media was where the NFS share landed on that machine. When the team migrated to Docker Compose, the containers inherited the NFS mount. The path made it into the .env because the application needed to know where to find files. Nobody changed it because it worked. The mount was still there. The path was valid. The application started. Files appeared where they should.\nThree years later, nobody thought about it at all. It was just how the media path was configured.\nWhat kubectl apply found The first kubectl apply for the media service ended with a pod stuck in CrashLoopBackOff. The container started. The entrypoint ran. The application tried to access /home/jenkins-slave/share_media/media. No such file or directory. No NFS mount. No runner.\nThe path didn\u0026rsquo;t document a design decision. It documented the machine that happened to be running at the time the .env was written.\nThis is what Factor IV of the twelve-factor app is warning against. Backing services — storage, queues, databases — should be attached resources, configured via URL or connection string, interchangeable between environments without code changes. A filesystem path on a shared host is not a backing service. It\u0026rsquo;s a physical assumption about the machine. When the machine changes, the assumption fails.\nThe path was the symptom The obvious first step was removing the runner reference:\nAPP__COLD_STORAGE__FILESYSTEM_PATH=\u0026#34;/share_media/media\u0026#34; APP__SHARE_STORAGE__FILESYSTEM_PATH=\u0026#34;/share_storage\u0026#34; Cleaner. No more CI references in a production config. Still not right. The application still assumed a POSIX filesystem — either a volume mount or a directory on the node. In Kubernetes, a volume shared between multiple pods requires a ReadWriteMany PersistentVolumeClaim. Most storage providers don\u0026rsquo;t support it. Those that do tend to be slow and expensive. And even where it works, you\u0026rsquo;ve replaced one shared filesystem assumption with another.\nRenaming the path bought time. It didn\u0026rsquo;t fix the problem.\nThe problem was that roughly twelve terabytes of images — originals and pre-generated derivatives in multiple formats — from multiple editorial brands — were treated as a directory. A directory can\u0026rsquo;t be mounted cleanly across pods. A backing service can.\nFlysystem as the shape of the solution The media service already had a Flysystem dependency. Three concrete adapters — local filesystem, AWS S3, Azure Blob — and one lazy adapter sitting on top:\n# config/packages/flysystem.yaml flysystem: storages: media.storage.local: adapter: \u0026#39;local\u0026#39; options: directory: \u0026#34;/\u0026#34; media.storage.aws: adapter: \u0026#39;aws\u0026#39; options: client: \u0026#39;aws_client_service\u0026#39; bucket: \u0026#39;media\u0026#39; streamReads: true media.storage: adapter: \u0026#39;lazy\u0026#39; options: source: \u0026#39;%env(APP__FLYSYSTEM_MEDIA_STORAGE)%\u0026#39; All application code depends on media.storage. It doesn\u0026rsquo;t know whether files live on the filesystem or in a cloud bucket. One environment variable determines which backend is active:\nAPP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws # production APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.local # local fallback still available The path is gone. The filesystem assumption is gone. What remains is a service name — an attached resource in the twelve-factor sense, configurable without rebuilding the image.\nThe same pattern extends to the thumbnail cache. LiipImagine generates resized images on demand; both the source originals and the generated cache go through separate Flysystem adapters:\nliip_imagine: loaders: default: flysystem: filesystem_service: \u0026#39;media.storage\u0026#39; default_cache: flysystem: filesystem_service: \u0026#39;media.cache.storage\u0026#39; Two environment variables, two buckets. The full pipeline — receive upload, store original, generate thumbnail, cache it — is cloud-portable without touching a line of PHP.\nWhat this doesn\u0026rsquo;t cover is moving the data. The lazy adapter changes one environment variable. Getting twelve terabytes from an NFS mount into an S3 bucket is a different project — a migration window, double-write during cutover, verification that nothing was missed.\nWhat Minio makes possible in CI Production uses S3. Local development uses Minio , an S3-compatible object store that runs in a Docker container. The AWS adapter talks to Minio locally and to S3 in production. The application doesn\u0026rsquo;t notice the difference:\n# local/CI APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws APP__MINIO_ENDPOINT=http://minio:9000 APP__MINIO_ACCESS_KEY=minioadmin APP__MINIO_SECRET_KEY=minioadmin The same code, the same adapter, a different endpoint. No mocking, no special test paths, no environment-specific branches.\nBut the CI configuration goes one step further. The Minio image used in the pipeline isn\u0026rsquo;t the standard upstream one — it\u0026rsquo;s a custom image built with test fixtures preloaded:\nFROM minio/minio:latest COPY tests/fixtures/ /fixtures_media/ Every CI run starts with a Minio instance that already contains the data the test suite expects. No setup script, no seed command, no \u0026ldquo;wait for fixtures to load\u0026rdquo; step before tests begin. The initial state of the test environment is part of the build artifact.\nFactor V applied to test infrastructure: the environment state is built, versioned, and immutable. The CI pipeline builds the Minio image from the same source and at the same commit as the application image. The test fixtures and the code that exercises them are always in sync.\nThe S3 tradeoff, honestly S3 introduces a latency cost that local storage doesn\u0026rsquo;t have. The first bytes of a file take 10 to 30 milliseconds to arrive from S3 — that\u0026rsquo;s the documented first-byte latency for the service, not a measurement on this specific workload.\nAt 300 requests per second, the reasoning for accepting it was this: most reads hit already-generated thumbnails in the S3-backed cache, not the original files. A freshly uploaded image pays the cold-miss penalty once, on the first thumbnail request. Everything after that is a cache hit. Whether the actual tail latency under load bore that reasoning out required performance testing that was tracked separately — the architecture decision and the validation were decoupled.\nThe tradeoff was accepted: predictable behavior across multiple pods, no shared-state problems, a storage layer that scales without coordination. The full measurement story belongs in the load test report, not here.\nThe ghost leaves The path /home/jenkins-slave no longer appears in the configuration. But what it pointed to was a coupling that predated Docker, predated microservices, predated any conversation about cloud migration. The CI runner and the production application shared a filesystem because they lived on the same machine. Nobody designed it that way. It accumulated.\nA kubectl apply error on a path that shouldn\u0026rsquo;t have existed forced the question: why does this application assume a specific CI runner is present on the host? The answer was \u0026ldquo;because it always has.\u0026rdquo; That\u0026rsquo;s not a reason. It\u0026rsquo;s a history.\nRenaming the path was a paper fix. Flysystem\u0026rsquo;s lazy adapter was the actual answer — not because it\u0026rsquo;s more elegant, but because it makes the storage backend a decision that belongs to the environment, not to the application. The container starts, reads one variable, connects to whatever is at the other end. It doesn\u0026rsquo;t know whether that\u0026rsquo;s a bucket in a data center or a container on a laptop.\nThe runner\u0026rsquo;s home directory is gone from the config. What replaced it is a service name. That\u0026rsquo;s the difference.\n","permalink":"https://guillaumedelre.github.io/2026/05/14/the-ghost-of-the-ci-runner/","summary":"\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-dotenv\" data-lang=\"dotenv\"\u003eAPP__COLD_STORAGE__FILESYSTEM_PATH=\u0026#34;/home/jenkins-slave/share_media/media\u0026#34;\nAPP__COLD_STORAGE__FILESYSTEM_PATH_CACHE=\u0026#34;/home/jenkins-slave/share_media/media/cache\u0026#34;\nAPP__COLD_STORAGE__RAW_IMAGE_PATH=\u0026#34;/home/jenkins-slave/share_media/media_raw\u0026#34;\nAPP__SHARE_STORAGE__FILESYSTEM_PATH=\u0026#34;/home/jenkins-slave/share_storage\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eThese lines were in the production \u003ccode\u003e.env\u003c/code\u003e of the media service. Not staging. Not a local override. Production, committed to the repository, read on every startup.\u003c/p\u003e\n\u003cp\u003eThe paths end where you\u0026rsquo;d expect: \u003ccode\u003e/media\u003c/code\u003e, \u003ccode\u003e/share_storage\u003c/code\u003e. They start somewhere more surprising: \u003ccode\u003e/home/jenkins-slave\u003c/code\u003e, the home directory of a CI runner from an old Jenkins setup.\u003c/p\u003e\n\u003ch2 id=\"how-a-runners-home-directory-ends-up-in-production-config\"\u003eHow a runner\u0026rsquo;s home directory ends up in production config\u003c/h2\u003e\n\u003cp\u003eThe platform had grown from a single machine. One server ran everything — the application, the CI runner, the database, the file storage. Files moved between the app and the CI system via NFS: a directory mounted on the same host, accessible to both the containers and the runner.\u003c/p\u003e","title":"The Ghost of the CI Runner"},{"content":"For years I wanted a homelab at home. A place of my own to host development tools, monitor my machines, run home automation, and experiment without risking breaking anything important. The idea is simple. Getting it running, a bit less so.\nBack then, Kubernetes didn\u0026rsquo;t exist yet. Options for running multiple services on a single machine came down to bash scripting, hand-written Nginx configs, and a lot of coffee. Tutorials on \u0026ldquo;homelab for humans\u0026rdquo; were nowhere to be found.\nThis tutorial is what I wish I had found back then. It\u0026rsquo;s been running for several years now. Not without evolving: services added, others dropped, choices revisited. But the foundation is there, stable — and that\u0026rsquo;s what success looks like in self-hosting.\nThe setup: ten self-hosted web services on a local machine, accessible from a browser via readable URLs, without touching DNS configuration, without renting a VPS, without managing TLS certificates. The ingredient that makes it possible: sslip.io , a public DNS service that encodes the IP directly in the domain name. service.192.168.1.10.sslip.io resolves to 192.168.1.10, with zero configuration, from any machine on the local network.\nThis tutorial is aimed at someone who knows Docker but is starting from scratch on self-hosted service orchestration.\nTable of contents Philosophy and architecture choices The building blocks Step-by-step setup Adding a new service Patterns and conventions Common pitfalls Conclusion References 1. Philosophy and architecture choices Goal Run multiple web services on a local machine, accessible from a browser via readable URLs, without touching DNS configuration, without renting a VPS, without managing TLS certificates.\nWhy Docker Compose and not something else? Docker Compose is the right level of complexity for a personal homelab. Kubernetes is too heavy for a single machine. Docker Swarm is in decline. Compose is simple, readable, versionable, and sufficient for dozens of services.\nWhy Traefik and not Nginx Proxy Manager? Nginx Proxy Manager (NPM) is a graphical interface for configuring Nginx as a reverse proxy. Routes are stored in a database and configured through a UI.\nTraefik automatically reads Docker container labels and generates its configuration on the fly. When a container starts with the right labels, Traefik discovers it and creates the route immediately, without restarting, without opening any UI.\nThis \u0026ldquo;configuration as code\u0026rdquo; approach has two major advantages:\nA service\u0026rsquo;s configuration lives in its compose.yaml, in the same place as everything else. Adding a service requires no changes to Traefik. Why Dockge and not Portainer? Portainer is a full Docker management tool: images, volumes, networks, individual containers\u0026hellip; powerful but complex.\nDockge is focused on a single thing: managing Docker Compose stacks. Its UI is minimal and intuitive. For a homelab where everything is managed through Compose, it\u0026rsquo;s sufficient and much more pleasant to use.\nWhy sslip.io? Web services need a hostname (e.g. dozzle.myserver.local) for Traefik to route correctly. The usual options:\nEdit /etc/hosts on every machine: tedious, not shareable. Set up a local DNS server (Pi-hole, AdGuard): requires additional infrastructure. Buy a domain and configure DNS: costs money and time. sslip.io is a public DNS service that automatically resolves \u0026lt;anything\u0026gt;.\u0026lt;IP\u0026gt;.sslip.io to \u0026lt;IP\u0026gt;. Example: dozzle.192.168.1.10.sslip.io resolves to 192.168.1.10. Nothing to configure — the DNS works everywhere without touching anything.\n2. The building blocks The shared Docker network All services and Traefik must share the same Docker network so Traefik can communicate with them. This network is called traefik and is created once:\ndocker network create traefik It is an external network (created outside any Compose file). Each compose.yaml declares it as external:\nnetworks: traefik: external: true Why external rather than internal to a Compose file? Because multiple independent stacks all need to connect to it. A network internal to a Compose file is only accessible to services within that file.\nTraefik: the reverse proxy Traefik listens on port 80 and routes HTTP requests to the right container based on the Host header.\nIts main configuration lives in stacks/traefik/docker/traefik/traefik.yaml:\napi: dashboard: true insecure: true entryPoints: web: address: :80 ping: address: :8082 providers: docker: endpoint: unix:///var/run/docker.sock exposedByDefault: false log: level: INFO global: sendAnonymousUsage: false exposedByDefault: false is important: Traefik ignores all containers by default. A container must explicitly opt in with the label traefik.enable: true. This prevents accidentally exposing services.\nThe ping entrypoint on port 8082 is dedicated to health checks. Separating it from the web entrypoint prevents health check requests from appearing in access logs.\nTo access the Docker daemon, Traefik mounts the socket:\nvolumes: - /var/run/docker.sock:/var/run/docker.sock Dockge: the stack manager Dockge runs inside a container itself (the compose.yaml at the root of the repo). It needs two things:\nAccess to the Docker socket to manage the other containers. Access to the stack directories to read and edit compose.yaml files. The critical point is the stack mount. Dockge launches stacks by passing absolute paths to the Docker daemon. These paths must be identical inside the Dockge container and on the host. The solution:\nvolumes: - ${PWD}/stacks:${PWD}/stacks environment: DOCKGE_STACKS_DIR: ${PWD}/stacks ${PWD} is a shell variable resolved at docker compose up time. It equals the current directory. If Dockge is launched from /home/user/homelab, the stacks folder will be mounted at /home/user/homelab/stacks on both sides. This is the only way to prevent Docker from creating ghost directories in the wrong place.\nPractical consequence: always run docker compose up -d from the root of the repo.\nDockge\u0026rsquo;s persistent data (configuration, history) lives in a named volume created in advance:\ndocker volume create homelab_dockge_data A named volume survives docker compose down -v. An anonymous volume would be destroyed with the stack.\n3. Step-by-step setup Step 1: clone and configure git clone \u0026lt;repo\u0026gt; homelab cd homelab Find the machine\u0026rsquo;s local IP:\nhostname -I | awk \u0026#39;{print $1}\u0026#39; # e.g.: 192.168.1.10 Create and edit the root .env:\ncp .env.example .env # Edit .env: # IP=192.168.1.10 # DOMAIN=sslip.io # COMPOSE_PROJECT_NAME=dockge ← important, see conventions section Step 2: Docker prerequisites docker network create traefik docker volume create homelab_dockge_data Step 3: start Dockge echo \u0026#34;STACKS_DIR=$(pwd)/stacks\u0026#34; \u0026gt;\u0026gt; .env docker compose up -d Dockge is accessible at http://\u0026lt;IP\u0026gt;:5001. It is exposed directly on port 5001, not through Traefik (Traefik is not running yet at this point). Create an admin account on first launch.\nStep 4: configure the stacks For each directory in stacks/, copy the .env.example:\nfor stack in stacks/*/; do cp \u0026#34;${stack}.env.example\u0026#34; \u0026#34;${stack}.env\u0026#34; done Then edit each .env to set IP and DOMAIN to the same values as in step 1. The COMPOSE_PROJECT_NAME value is pre-filled with the folder name — do not change it (see conventions section).\nFor filebrowser, also set FILEBROWSER_ROOT to the local path to expose.\nStep 5: start the stacks from Dockge From the Dockge interface (http://\u0026lt;IP\u0026gt;:5001), in this order:\n1. Traefik first\nTraefik must be running before the other services. Without Traefik, routes don\u0026rsquo;t exist and services are unreachable via their URL.\nAfter starting, verify Traefik is healthy:\ndocker ps --filter name=traefik 2. The other stacks in any order\nEach stack automatically registers itself with Traefik via its Docker labels. Traefik discovers new containers in real time.\n3. Homepage last\nHomepage reads Docker labels from all running containers at startup to build the dashboard. Starting it last ensures it discovers all active services from the first launch.\n4. Adding a new service Here is the compose.yaml template for any new service:\nservices: myservice: image: vendor/myservice:latest restart: unless-stopped healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;wget -qO- http://127.0.0.1:\u0026lt;PORT\u0026gt;/ || exit 1\u0026#34;] interval: 30s timeout: 10s retries: 3 start_period: 10s labels: # Homepage - auto-discovery in dashboard homepage.group: tools homepage.name: My Service homepage.icon: https://cdn.jsdelivr.net/gh/selfhst/icons/webp/myservice.webp homepage.href: http://${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN} # Traefik - HTTP routing traefik.enable: true traefik.http.routers.myservice.entrypoints: web traefik.http.routers.myservice.rule: Host(`${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}`) traefik.http.services.myservice.loadbalancer.server.port: \u0026lt;PORT\u0026gt; networks: - traefik networks: traefik: external: true And the associated .env.example:\nCOMPOSE_PROJECT_NAME=myservice IP=127.0.0.1 DOMAIN=sslip.io The folder name determines the subdomain. If the folder is called myservice, the service will be accessible at myservice.\u0026lt;IP\u0026gt;.\u0026lt;DOMAIN\u0026gt;. That\u0026rsquo;s it.\nTo find services worth adding, selfh.st is an excellent resource: it\u0026rsquo;s a catalog of self-hosted software organized by category (media, security, productivity, monitoring\u0026hellip;), with a description, screenshot, and GitHub link for each. The site also publishes a weekly newsletter on new releases.\nChecklist for a new service Create stacks/\u0026lt;subdomain-name\u0026gt;/compose.yaml Create stacks/\u0026lt;subdomain-name\u0026gt;/.env.example with COMPOSE_PROJECT_NAME=\u0026lt;name\u0026gt; Copy .env.example to .env and fill in IP/DOMAIN Check the port in the Traefik labels Choose the Homepage group: infra, monitoring, tools Find the icon on selfhst/icons Add persistent data in a volume if needed Start from Dockge and verify the container is healthy 5. Patterns and conventions The ${COMPOSE_PROJECT_NAME} variable Docker Compose automatically sets COMPOSE_PROJECT_NAME to the stack folder name. We use it to build URLs dynamically:\ntraefik.http.routers.dozzle.rule: Host(`${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN}`) homepage.href: http://${COMPOSE_PROJECT_NAME}.${IP}.${DOMAIN} Advantage: no *_HOST variable to maintain in each .env. Renaming the folder automatically changes the subdomain.\nWarning: in the .env, COMPOSE_PROJECT_NAME must be defined explicitly with the stack folder name. Without it, Docker Compose uses the current directory name at launch time, which can produce unexpected values depending on where the command is run from.\nHomepage groups Services are organized into three groups in the dashboard:\nGroup Services infra Traefik , Dockge , Watchtower , Homepage monitoring Dozzle , Glances , Uptime Kuma tools FileBrowser , IT-Tools , Stirling PDF This grouping is specific to this homelab, not an enforced convention. Homepage accepts any value for homepage.group: you can create as many groups as needed and name them however you like (media, home-automation, dev\u0026hellip;). The dashboard reorganizes automatically.\nHealth checks All services have a health check. This is crucial because Traefik silently ignores unhealthy containers: a service with a failing health check will not appear in routing, even with traefik.enable: true.\nThree edge cases encountered in practice:\n1. localhost does not always resolve to 127.0.0.1\nIn some minimal images, localhost is not resolved. Use 127.0.0.1 explicitly:\ntest: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;wget -qO- http://127.0.0.1:8080/ || exit 1\u0026#34;] 2. Images without a shell (scratch-based)\nImages based on scratch (e.g. Dozzle) do not contain /bin/sh. CMD-SHELL fails. Use the embedded binary:\ntest: [\u0026#34;CMD\u0026#34;, \u0026#34;/dozzle\u0026#34;, \u0026#34;healthcheck\u0026#34;] 3. Images without wget or curl\nSome Node.js or JVM images have neither wget nor curl. Possible solutions:\nIf Node.js is available: node -e \u0026quot;require('http').get('http://localhost:PORT', r =\u0026gt; process.exit(r.statusCode \u0026lt; 400 ? 0 : 1)).on('error', () =\u0026gt; process.exit(1))\u0026quot; If curl is available: curl -fs http://127.0.0.1:PORT/ If the app binary exposes a healthcheck subcommand: use it directly. Data persistence For services that have data (configuration, user accounts, database):\nvolumes: - ./docker/data:/path/in/container The ./docker/ folder lives inside the stack directory and can be versioned, except for runtime data which goes in .gitignore.\nRule: add stacks/\u0026lt;service\u0026gt;/docker/ to .gitignore if the folder contains data that should not be committed (SQLite databases, uploads\u0026hellip;).\nTraefik label conventions By convention, the name used in Traefik labels (traefik.http.routers.\u0026lt;name\u0026gt;) matches the Docker service name in compose.yaml. In practice, align it with the folder name:\nstacks/it-tools/ → service: ittools → traefik.http.routers.ittools.* This is not a technical constraint from Traefik, just a readability convention.\n6. Common pitfalls Dockge: Stop then Start, not Restart When a compose.yaml is modified from an IDE and the changes need to be applied, use Stop + Start from Dockge, not \u0026ldquo;Restart\u0026rdquo;. Restart restarts the existing container without re-reading the compose.yaml. Stop + Start recreates the container with the new configuration.\nModified labels: restart Homepage Homepage reads Docker labels at startup. If homepage.group or homepage.name is changed for a service, Homepage won\u0026rsquo;t see it until it is restarted.\nContainer starts but is not routable Check in order:\ndocker ps: is the container healthy? Traefik ignores unhealthy containers. Is the container on the traefik network? docker inspect \u0026lt;container\u0026gt; --format \u0026#39;{{json .NetworkSettings.Networks}}\u0026#39; Is the label traefik.enable: true present? Does the Host(...) rule match the URL being tested? Mounting non-existent files under Docker Desktop / WSL When Docker Desktop (WSL) mounts a file that does not yet exist on the host, it creates a directory instead. This ghost directory then blocks the mount of the actual file. Symptom: the container fails to start with a mount error.\nSolution: ensure the file exists on the host before starting the container, or use a directory mount instead of a file mount.\nWatchtower: Docker API too old On some configurations, Watchtower tries to communicate with the daemon starting the negotiation at API v1.25 (its historical minimum). Recent versions of Docker reject this version. Symptom: the container restarts in a loop with client version 1.25 is too old. Minimum supported API version is 1.40.\nFix in the Watchtower compose.yaml:\nenvironment: DOCKER_API_VERSION: \u0026#34;1.40\u0026#34; 1.40 is the value to use, regardless of your Docker version. It is not your exact version — it is the minimum the daemon accepts, as stated in the error message. To check the actual API version of your daemon:\ndocker version --format \u0026#39;{{.Server.APIVersion}}\u0026#39; ${PWD} in Dockge\u0026rsquo;s compose file ${PWD} is not a .env variable — it is a shell variable resolved at docker compose up time. It equals the current terminal directory. Running docker compose up -d from any other directory will produce a wrong value and break stack volume mounts.\nThis homelab is designed to run on a Linux machine or WSL. All commands have been tested on Ubuntu/WSL2 with Docker Desktop.\nConclusion I\u0026rsquo;m well aware this tutorial doesn\u0026rsquo;t cover everything. We could have added authentication in front of each service, run the whole thing over HTTPS, set up a socket proxy to limit the Docker daemon\u0026rsquo;s exposure, or pinned precise image versions. But each of those points would have considerably lengthened the article and the complexity of the setup. The goal was to start with something functional and maintainable, not to build a fortress on day one.\nThe perfect homelab doesn\u0026rsquo;t exist. The one that runs, does.\nguillaumedelre/homelab Docker Compose homelab with Traefik — independent stacks, auto-configured dashboard, and zero DNS configuration using sslip.io.\nReferences Project Link sslip.io sslip.io selfh.st selfh.st Traefik github.com/traefik/traefik Dockge github.com/louislam/dockge Homepage github.com/gethomepage/homepage Dozzle github.com/amir20/dozzle Glances github.com/nicolargo/glances FileBrowser github.com/gtsteffaniak/filebrowser IT-Tools github.com/CorentinTh/it-tools Stirling PDF github.com/Stirling-Tools/Stirling-PDF Uptime Kuma github.com/louislam/uptime-kuma Watchtower github.com/containrrr/watchtower selfhst/icons github.com/selfhst/icons ","permalink":"https://guillaumedelre.github.io/2026/02/17/building-a-self-hosted-homelab-with-docker-compose-and-traefik/","summary":"\u003cp\u003eFor years I wanted a homelab at home. A place of my own to host development tools, monitor my machines, run home automation, and experiment without risking breaking anything important. The idea is simple. Getting it running, a bit less so.\u003c/p\u003e\n\u003cp\u003eBack then, Kubernetes didn\u0026rsquo;t exist yet. Options for running multiple services on a single machine came down to bash scripting, hand-written Nginx configs, and a lot of coffee. Tutorials on \u0026ldquo;homelab for humans\u0026rdquo; were nowhere to be found.\u003c/p\u003e","title":"Building a self-hosted homelab with Docker Compose and Traefik"},{"content":"Symfony 8.0 shipped November 27, 2025, same day as 7.4. It requires PHP 8.4 and drops everything that was deprecated in 7.4. The two most interesting changes are what it stops doing and what it starts doing with PHP 8.4.\nNative lazy objects Symfony\u0026rsquo;s proxy system, used for lazy service initialization and Doctrine\u0026rsquo;s entity proxies, has historically relied on code generation. The proxy classes were generated at cache warmup, stored as files, and loaded when needed. It worked, but it added real complexity: generated files to manage, cache to invalidate, code that looked nothing like the class it proxied.\nPHP 8.4 added native lazy objects. Symfony 8.0 uses them. The LazyGhostTrait and LazyProxyTrait that powered the old system are removed. Proxy creation is now a runtime operation backed by the engine itself, not a code generation step.\nFor application developers the change is mostly invisible: lazy services still work. For framework and library authors, a significant surface of complexity just disappears.\nFormFlow Multi-step forms have always been a DIY exercise in Symfony. Session management, step tracking, partial validation, navigation between steps: every project rolled its own solution or pulled in a third-party bundle.\n8.0 introduces FormFlow: a built-in mechanism for multi-step form wizards. Steps are defined as a sequence of form types, partial validation is scoped to the current step, and session management is handled automatically.\n#[AsFormFlow] class CheckoutFlow extends AbstractFormFlow { protected function defineSteps(): Steps { return Steps::create() -\u0026gt;add(\u0026#39;shipping\u0026#39;, ShippingType::class) -\u0026gt;add(\u0026#39;payment\u0026#39;, PaymentType::class) -\u0026gt;add(\u0026#39;review\u0026#39;, ReviewType::class); } } XML and fluent PHP config removed The 7.4 deprecation of the fluent PHP configuration format becomes a hard removal in 8.0. XML configuration also exits as a first-class format. The supported formats for application configuration are now YAML and PHP arrays. The footprint shrinks, but what remains is genuinely better.\nWhat else is gone PHP 8.2 and 8.3 support (8.4 minimum) The ContainerAwareInterface and ContainerAwareTrait Symfony\u0026rsquo;s internal use of LazyGhostTrait and LazyProxyTrait HTTP method override for GET and HEAD (only POST makes sense semantically) Symfony 8.0 is a clean break, and that kind of break only becomes possible when the PHP floor rises. PHP 8.4\u0026rsquo;s lazy objects are the clearest example: the feature now exists in the language, so the framework can just stop implementing it.\nConsole becomes more ergonomic for invokable commands Invokable commands get a significant upgrade. The #[Input] attribute turns a DTO into the command\u0026rsquo;s argument/option bag. No more calling $input-\u0026gt;getArgument() inside the handler:\n#[AsCommand(name: \u0026#39;app:send-report\u0026#39;)] class SendReportCommand { public function __invoke( #[Input] SendReportInput $input, ): int { // $input-\u0026gt;email, $input-\u0026gt;dryRun, etc. return Command::SUCCESS; } } BackedEnum is supported in invokable commands, so an option declared as a Status enum gets validated and cast automatically. Interactive commands get #[Interact] and #[Ask] attributes to declare question prompts inline. CommandTester works with invokable commands without any extra wiring.\nRouting finds its own controllers Routes defined via #[Route] on controller classes are auto-registered without needing an explicit resource: entry in config/routes.yaml. The tag routing.controller is applied automatically. You still control which directories are scanned, but your YAML config shrinks to a pointer at a directory rather than a manual file list.\n#[Route] also gains a _query parameter for setting query parameters at generation time, and multiple environments in the env option.\nSecurity: CSRF and OIDC get better tooling #[IsCsrfTokenValid] gets a $tokenSource argument so you can specify where the token comes from (header, cookie, form field) rather than relying on a fixed convention. SameOriginCsrfTokenManager adds Sec-Fetch-Site header validation, a browser-native CSRF protection mechanism that doesn\u0026rsquo;t need token injection at all.\nThe security:oidc-token:generate command creates tokens for testing OIDC-protected endpoints locally. Multiple OIDC discovery endpoints are supported now, useful in multi-tenant setups where each tenant has its own identity provider.\nTwo new Twig functions: access_decision() and access_decision_for_user() expose the authorization voter result in templates without going through the security facade. #[IsGranted] can be subclassed for repeated authorization patterns that deserve their own named attribute.\nObjectMapper and JsonStreamer leave experimental Both components introduced in 7.x graduate to stable in 8.0. ObjectMapper maps between objects without hand-written transformers, using attribute-based configuration. JsonStreamer reads and writes large JSON without loading the full document into memory, and it now supports synthetic properties: virtual fields computed at serialization time.\nJsonStreamer also drops its dependency on nikic/php-parser. The code generation for the reader/writer now uses a simpler internal mechanism, cutting a heavy dev dependency.\nUid defaults to UUIDv7 UuidFactory now generates UUIDv7 by default instead of UUIDv4. The difference: v7 is time-ordered, so generated UUIDs sort chronologically. That matters a lot for database index performance. MockUuidFactory provides deterministic UUID generation in tests.\nYaml raises an error on duplicate keys Previously, a YAML file with two identical keys silently kept the last one. 8.0 raises a parse error. This catches real bugs: duplicate keys in services.yaml or config/packages/*.yaml are almost always copy-paste mistakes and you definitely want to know about them.\nValidator: Video constraint and wildcard protocols A Video constraint joins the Image constraint for validating uploaded video files (MIME type, duration, codec). The Url constraint accepts protocols: ['*'] to allow any RFC 3986-compliant scheme, useful when storing arbitrary URLs that include git+ssh://, file://, or custom app schemes.\nMessenger: SQS native retry and new events SQS transport can now use its own native retry and dead-letter queue configuration instead of Symfony\u0026rsquo;s retry middleware. For high-volume queues on AWS, this removes a round-trip through PHP for transient failures. A MessageSentToTransportsEvent fires after a message is dispatched, carrying information about which transports actually received it.\nmessenger:consume gets --exclude-receivers to pair with --all.\nMailer: Microsoft Graph transport A new transport sends mail via the Microsoft Graph API, which is what Microsoft recommends for applications on Azure Active Directory these days. The other options (SMTP relay, Exchange EWS) still work, but Graph is the right choice for new Azure deployments.\nWorkflow: weighted transitions Transitions can now declare weights. When multiple transitions are enabled from the same place, the highest-weight one wins. This lets you express priority directly in the workflow definition without adding a guard that reads an external counter.\nreturn (new Definition(states: [\u0026#39;draft\u0026#39;, \u0026#39;review\u0026#39;, \u0026#39;published\u0026#39;])) -\u0026gt;addTransition(new Transition(\u0026#39;publish\u0026#39;, \u0026#39;review\u0026#39;, \u0026#39;published\u0026#39;, weight: 10)) -\u0026gt;addTransition(new Transition(\u0026#39;reject\u0026#39;, \u0026#39;review\u0026#39;, \u0026#39;draft\u0026#39;, weight: 1)); Lock: LockKeyNormalizer LockKeyNormalizer normalizes a lock key to a consistent string before hashing. Useful when the key is derived from user input or external data that may vary in whitespace or casing: the normalizer makes sure the same logical key always maps to the same lock.\nHttpFoundation: QUERY method and cleaner body parsing The IETF QUERY method (a safe, idempotent method with a body, unlike GET) is now supported throughout the stack: Request, HTTP cache, WebProfiler, and HttpClient. If you build search APIs that need a structured request body and also want caching, QUERY is the right semantic choice.\nRequest::createFromGlobals() now parses the body of PUT, DELETE, PATCH, and QUERY requests automatically.\nConfig: JSON schema for YAML validation Symfony 8.0 auto-generates a JSON Schema file for each configuration section. IDEs that support JSON Schema for YAML files (VS Code, PhpStorm) can now validate config/packages/*.yaml against these schemas and provide autocompletion without any plugin. The schema is generated during cache warmup and placed at config/reference.php.\nRuntime: FrankenPHP auto-detection The Runtime component detects FrankenPHP automatically and activates worker mode without any extra package or environment variable. If $_SERVER['APP_RUNTIME'] is set, that runtime class takes precedence. You can also pick the error renderer based on APP_RUNTIME_MODE, which is useful when running the same codebase in HTTP and CLI contexts with different error presentation needs.\n","permalink":"https://guillaumedelre.github.io/2026/01/12/symfony-8.0-php-8.4-minimum-native-lazy-objects-and-formflow/","summary":"\u003cp\u003eSymfony 8.0 shipped November 27, 2025, same day as 7.4. It requires PHP 8.4 and drops everything that was deprecated in 7.4. The two most interesting changes are what it stops doing and what it starts doing with PHP 8.4.\u003c/p\u003e\n\u003ch2 id=\"native-lazy-objects\"\u003eNative lazy objects\u003c/h2\u003e\n\u003cp\u003eSymfony\u0026rsquo;s proxy system, used for lazy service initialization and Doctrine\u0026rsquo;s entity proxies, has historically relied on code generation. The proxy classes were generated at cache warmup, stored as files, and loaded when needed. It worked, but it added real complexity: generated files to manage, cache to invalidate, code that looked nothing like the class it proxied.\u003c/p\u003e","title":"Symfony 8.0: PHP 8.4 minimum, native lazy objects, and FormFlow"},{"content":"Symfony 7.4 landed November 2025, alongside 8.0. It\u0026rsquo;s the last LTS of the 7.x line: PHP 8.2 minimum, three years of bug fixes, four of security. For teams that can\u0026rsquo;t or won\u0026rsquo;t follow 8.0\u0026rsquo;s PHP 8.4 requirement, 7.4 is where you land.\nMessage signing in Messenger Transport security in Messenger has always been the application\u0026rsquo;s problem to solve. 7.4 adds message signing: a stamp-based mechanism that signs dispatched messages and validates signatures on reception.\nThe target use case is multi-tenant or external transport scenarios where you need cryptographic proof that a message wasn\u0026rsquo;t tampered with or injected from outside. Configuration lives at the transport level:\nframework: messenger: transports: async: dsn: \u0026#39;%env(MESSENGER_TRANSPORT_DSN)%\u0026#39; options: signing_key: \u0026#39;%env(MESSENGER_SIGNING_KEY)%\u0026#39; PHP array configuration Symfony\u0026rsquo;s configuration formats have always been YAML (default), XML, and PHP. The PHP format existed but it was awkward: a fluent builder DSL that required method chaining and gave your IDE nothing useful to work with.\n7.4 swaps the fluent format for standard PHP arrays. IDEs can now actually analyze it, config/reference.php is auto-generated as a type-annotated reference, and the result reads like data rather than code:\nreturn static function (FrameworkConfig $framework): void { $framework-\u0026gt;router()-\u0026gt;strictRequirements(null); $framework-\u0026gt;session()-\u0026gt;enabled(true); }; The fluent format is deprecated. Arrays are the future, and honestly it\u0026rsquo;s a better format.\nOIDC improvements #[IsSignatureValid] validates signed URLs directly in controllers, cutting out the boilerplate of manual validation. OpenID Connect now supports multiple discovery endpoints, and a new security:oidc-token:generate command makes dev and testing a lot less painful.\nThe support window 7.4 LTS: bugs until November 2028, security fixes until November 2029. The path to 8.4 LTS (the next long-term target) goes through 7.4\u0026rsquo;s deprecation notices and the PHP 8.4 upgrade. Fix the deprecations now and the jump to 8.x will be much less painful.\nAttributes get more precise #[CurrentUser] now accepts union types, which matters in practice when a route can be reached by more than one user class:\npublic function index(#[CurrentUser] AdminUser|Customer $user): Response #[Route] accepts an array for the env option, so a debug route active only in dev and test no longer needs two separate definitions. #[AsDecorator] is now repeatable, meaning one class can decorate multiple services at once. #[AsEventListener] method signatures accept union event types. #[IsGranted] gets a methods option to scope an authorization check to specific HTTP verbs without duplicating the route.\nRequest class stops doing too much Request::get() is deprecated, and honestly good riddance. The method searched route attributes, then query parameters, then request body, in that order, silently returning whatever it found first. That ambiguity caused real bugs. It\u0026rsquo;s gone in 8.0; in 7.4 it still works but triggers a deprecation. The replacements are explicit: $request-\u0026gt;attributes-\u0026gt;get(), $request-\u0026gt;query-\u0026gt;get(), $request-\u0026gt;request-\u0026gt;get().\nBody parsing for PUT, PATCH, DELETE, and QUERY requests arrives at the same time. Previously Symfony only parsed application/x-www-form-urlencoded and multipart/form-data for POST. Those same content types now get parsed for the other writable methods too, which kills a common REST API workaround.\nHTTP method override for GET, HEAD, CONNECT, and TRACE is deprecated. Overriding a safe method with a header was always semantically broken anyway. You can now explicitly allow only the methods that make sense for your app:\nRequest::setAllowedHttpMethodOverride([\u0026#39;PUT\u0026#39;, \u0026#39;PATCH\u0026#39;, \u0026#39;DELETE\u0026#39;]); Workflows accept BackedEnums Workflow places and transitions can now be defined with PHP backed enums, both in YAML (via the !php/enum tag) and in PHP config. The marking store works with enum values directly, so your domain model and your workflow definition finally use the same types:\nframework: workflows: blog_publishing: initial_marking: !php/enum App\\Status\\PostStatus::Draft places: !php/enum App\\Status\\PostStatus transitions: publish: from: !php/enum App\\Status\\PostStatus::Review to: !php/enum App\\Status\\PostStatus::Published Extending validation and serialization for third-party classes Ever needed to add validation or serialization metadata to a class from a bundle you don\u0026rsquo;t own? 7.4 has #[ExtendsValidationFor] and #[ExtendsSerializationFor] for that. You write a companion class with your extra annotations, point the attribute at the target class, and Symfony merges the metadata at container compilation time:\n#[ExtendsValidationFor(UserRegistration::class)] abstract class UserRegistrationValidation { #[Assert\\NotBlank(groups: [\u0026#39;my_app\u0026#39;])] #[Assert\\Length(min: 3, groups: [\u0026#39;my_app\u0026#39;])] public string $name = \u0026#39;\u0026#39;; #[Assert\\Email(groups: [\u0026#39;my_app\u0026#39;])] public string $email = \u0026#39;\u0026#39;; } Symfony verifies at compile time that the declared properties actually exist on the target class. A rename won\u0026rsquo;t silently break your validation.\nDX: the things that don\u0026rsquo;t headline but matter The Question helper in Console accepts a timeout. Ask the user to confirm something, and if they don\u0026rsquo;t respond in N seconds, the default answer kicks in. Very handy in deployment scripts that can\u0026rsquo;t afford to wait forever for a human.\nmessenger:consume gets --exclude-receivers. Combined with --all, it lets you consume from every transport except specific ones:\nbin/console messenger:consume --all --exclude-receivers=low_priority FrankenPHP worker mode is now auto-detected. If the process is running inside FrankenPHP, Symfony switches to worker mode automatically. No extra package needed.\nThe debug:router command hides the Scheme and Host columns when all routes use ANY, which removes a lot of noise from the default output. HTTP methods are now color-coded too.\nFunctional tests get $client-\u0026gt;getSession() before the first request. Previously you had to make at least one request to access the session, which was annoying. Now you can pre-seed CSRF tokens or A/B testing flags up front:\n$session = $client-\u0026gt;getSession(); $session-\u0026gt;set(\u0026#39;_csrf/checkout\u0026#39;, \u0026#39;test-token\u0026#39;); $session-\u0026gt;save(); Lock: DynamoDB store DynamoDbStore lands as a new Lock backend. Useful in AWS-native deployments where Redis isn\u0026rsquo;t in the stack, and it works exactly like any other store:\n$store = new DynamoDbStore(\u0026#39;dynamodb://default/locks\u0026#39;); $factory = new LockFactory($store); Doctrine bridge: day and time point types Two new Doctrine column types: day_point stores a date-only value (no time component) and time_point stores a time-only value, both mapping to DatePoint. Good when your domain genuinely separates date from time:\n#[ORM\\Column(type: \u0026#39;day_point\u0026#39;)] public DatePoint $birthDate; #[ORM\\Column(type: \u0026#39;time_point\u0026#39;)] public DatePoint $openingTime; Routing: explicit query parameters The _query key in URL generation lets you set query parameters explicitly, separate from route parameters. This matters when a route parameter and a query parameter share the same name:\n$url = $urlGenerator-\u0026gt;generate(\u0026#39;report\u0026#39;, [ \u0026#39;site\u0026#39; =\u0026gt; \u0026#39;fr\u0026#39;, \u0026#39;_query\u0026#39; =\u0026gt; [\u0026#39;site\u0026#39; =\u0026gt; \u0026#39;us\u0026#39;], ]); // /report/fr?site=us WebLink: parsing incoming Link headers HttpHeaderParser parses Link response headers into structured objects. Before this, parsing Link headers from API responses meant either pulling in a third-party library or writing regex. The use case is HTTP APIs that advertise related resources or pagination via Link headers, like GitHub\u0026rsquo;s API does.\nHTML5 parsing gets faster on PHP 8.4 DomCrawler and HtmlSanitizer switch to PHP 8.4\u0026rsquo;s native HTML5 parser when available. No code changes needed on your end. The native parser is faster and more spec-compliant than the previous fallback. On PHP 8.2 or 8.3 nothing changes.\nTranslation: StaticMessage StaticMessage implements TranslatableInterface but intentionally doesn\u0026rsquo;t translate. It passes the string through unchanged regardless of locale. The use case is API responses that must stay in a fixed language regardless of the user\u0026rsquo;s locale, or audit log entries where you need to preserve the original text as-is.\n","permalink":"https://guillaumedelre.github.io/2026/01/10/symfony-7.4-lts-message-signing-php-config-arrays-and-the-last-7.x/","summary":"\u003cp\u003eSymfony 7.4 landed November 2025, alongside 8.0. It\u0026rsquo;s the last LTS of the 7.x line: PHP 8.2 minimum, three years of bug fixes, four of security. For teams that can\u0026rsquo;t or won\u0026rsquo;t follow 8.0\u0026rsquo;s PHP 8.4 requirement, 7.4 is where you land.\u003c/p\u003e\n\u003ch2 id=\"message-signing-in-messenger\"\u003eMessage signing in Messenger\u003c/h2\u003e\n\u003cp\u003eTransport security in Messenger has always been the application\u0026rsquo;s problem to solve. 7.4 adds message signing: a stamp-based mechanism that signs dispatched messages and validates signatures on reception.\u003c/p\u003e","title":"Symfony 7.4 LTS: message signing, PHP config arrays, and the last 7.x"},{"content":"PHP 8.5 shipped November 20th. Two features define this release: the pipe operator and the URI extension. They solve different problems, but both share the same motivation: making common operations less awkward to express.\nThe pipe operator Functional pipelines in PHP have always been a mess. Chaining transformations meant either nesting function calls inside out, or breaking them into intermediate variables:\n// before — read right to left $result = array_sum(array_map(\u0026#39;strlen\u0026#39;, array_filter($strings, \u0026#39;strlen\u0026#39;))); // or verbose but readable $filtered = array_filter($strings, \u0026#39;strlen\u0026#39;); $lengths = array_map(\u0026#39;strlen\u0026#39;, $filtered); $result = array_sum($lengths); // after — read left to right $result = $strings |\u0026gt; array_filter(?, \u0026#39;strlen\u0026#39;) |\u0026gt; array_map(\u0026#39;strlen\u0026#39;, ?) |\u0026gt; array_sum(?); The |\u0026gt; operator passes the left-hand value into the right-hand expression. The ? placeholder marks where it goes. Pipelines now read in the order operations happen: left to right, top to bottom.\nThis pairs well with first-class callables from PHP 8.1. The two features compose nicely:\n$result = $input |\u0026gt; trim(...) |\u0026gt; strtolower(...) |\u0026gt; $this-\u0026gt;normalize(...); The URI extension Handling URIs in PHP has always meant either reaching for a third-party library or cobbling together parse_url() (returns an array, not an object), http_build_query(), and manual string concatenation.\nThe new Uri extension gives you a proper object-oriented API:\n$uri = Uri\\Uri::parse(\u0026#39;https://example.com/path?query=value#fragment\u0026#39;); $modified = $uri-\u0026gt;withPath(\u0026#39;/new-path\u0026#39;)-\u0026gt;withQuery(\u0026#39;key=val\u0026#39;); echo $modified; // https://example.com/new-path?key=val#fragment Immutable value objects, RFC-compliant parsing, modify individual components without parsing and reconstructing the whole string. Long overdue.\n#[\\NoDiscard] A new attribute that generates a warning when the return value is ignored:\n#[\\NoDiscard(\u0026#34;Use the returned collection, the original is unchanged\u0026#34;)] public function filter(callable $fn): static { ... } Useful for immutable methods where ignoring the return value is almost certainly a bug. Common in other languages for years, now in PHP where it belongs.\nclone with Cloning an object with modified properties without using property hooks or a custom with() method:\n$updated = clone($point) with { x: 10, y: 20 }; Clean syntax for a pattern readonly objects needed: you clone to \u0026ldquo;modify\u0026rdquo; since direct mutation isn\u0026rsquo;t allowed.\nPHP 8.5 has a functional streak. The pipe operator and URI extension together make data transformation code meaningfully easier to read. The language keeps moving in a consistent direction.\nClosures in constant expressions A constraint that\u0026rsquo;s been baked in since PHP 5: constant expressions (attribute arguments, property defaults, parameter defaults, const declarations) couldn\u0026rsquo;t contain closures or first-class callables. 8.5 removes that.\n#[Validate(fn($v) =\u0026gt; $v \u0026gt; 0)] public int $count = 0; const NORMALIZER = strtolower(...); class Config { public function __construct( public readonly Closure $transform = trim(...), ) {} } This is the missing piece that makes attributes genuinely expressive for validation and transformation rules. Before 8.5, you had to pass class names or string references to attributes and let the framework look them up. Now the callable lives directly in the attribute.\nAttributes on constants The #[\\Deprecated] attribute from 8.4 couldn\u0026rsquo;t be applied to const declarations. 8.5 adds attribute support for constants generally:\nconst OLD_LIMIT = 100; #[\\Deprecated(\u0026#39;Use RATE_LIMIT instead\u0026#39;, since: \u0026#39;3.0\u0026#39;)] const API_TIMEOUT = 30; const RATE_LIMIT = 60; ReflectionConstant, a new reflection class in 8.5, exposes getAttributes() so tools can read them. Combined with closures in constant expressions, attributes on constants become a real metadata layer for compile-time values.\n#[\\Override] extends to properties PHP 8.3 brought #[\\Override] for methods. 8.5 extends it to properties:\nclass Base { public string $name = \u0026#39;default\u0026#39;; } class Derived extends Base { #[\\Override] public string $name = \u0026#39;derived\u0026#39;; } If the property doesn\u0026rsquo;t exist in the parent, PHP throws an error. Particularly useful with property hooks from 8.4: you can now signal that a hooked property is intentionally overriding a parent\u0026rsquo;s.\nStatic asymmetric visibility 8.4 introduced asymmetric visibility (public private(set)) for instance properties. 8.5 brings that to static properties too:\nclass Registry { public static private(set) array $items = []; public static function register(string $key, mixed $value): void { self::$items[$key] = $value; } } echo Registry::$items[\u0026#39;foo\u0026#39;]; // readable Registry::$items[\u0026#39;bar\u0026#39;] = 1; // Error: cannot write outside class Straightforward pattern: expose a static collection for reading, block external mutation.\nConstructor promotion for final properties Property promotion in constructors has existed since PHP 8.0. The final modifier on promoted properties was the missing piece, 8.5 adds it:\nclass ValueObject { public function __construct( public final readonly string $id, public final readonly string $name, ) {} } A subclass can\u0026rsquo;t override $id or $name with a property of the same name. The final readonly combination on promoted properties makes value objects as locked down as possible without sealing the whole class.\nCasts in constant expressions Another gap in constant expressions: no type casts. 8.5 allows them:\nconst PRECISION = (int) 3.7; // 3 const THRESHOLD = (float) \u0026#39;1.5\u0026#39;; // 1.5 const FLAG = (bool) 1; // true Sounds minor until you have configuration constants derived from environment variables that need type coercion right at the declaration.\nFatal errors include backtraces Before 8.5, a fatal error (out-of-memory, stack overflow, type error in certain contexts) produced a message with no context about where in the code it happened. Finding the cause meant inserting debug logging and reproducing.\n8.5 adds stack backtraces to fatal error messages, in the same format as exception backtraces. A new INI directive, fatal_error_backtraces, controls the behavior. It\u0026rsquo;s on by default.\narray_first() and array_last() PHP has had reset() and end() for accessing the first and last elements of an array since PHP 3. Both mutate the array\u0026rsquo;s internal pointer (not safe to call on a reference), and they return false for empty arrays in a way that\u0026rsquo;s indistinguishable from a stored false value.\n$values = [10, 20, 30]; $first = array_first($values); // 10 $last = array_last($values); // 30 $first = array_first([]); // null The new functions return null for empty arrays, don\u0026rsquo;t touch the internal pointer, and work on any array expression without needing a variable. reset($this-\u0026gt;getItems()) was a deprecation warning waiting to happen.\nget_error_handler() and get_exception_handler() PHP has set_error_handler() and set_exception_handler(). Getting the current handler meant either storing it yourself before setting it, or calling set_error_handler(null) and capturing what came back, which also cleared the handler in the process.\n8.5 adds:\n$current = get_error_handler(); $current = get_exception_handler(); Handy in middleware chains where you want to wrap the existing handler without losing it, or in tests where you want to verify a handler was actually installed.\nIntlListFormatter Formatting a list with locale-appropriate conjunctions has always needed manual string assembly. 8.5 adds IntlListFormatter:\n$formatter = new IntlListFormatter(\u0026#39;en_US\u0026#39;, IntlListFormatter::TYPE_AND); echo $formatter-\u0026gt;format([\u0026#39;apples\u0026#39;, \u0026#39;oranges\u0026#39;, \u0026#39;pears\u0026#39;]); // \u0026#34;apples, oranges, and pears\u0026#34; $formatter = new IntlListFormatter(\u0026#39;fr_FR\u0026#39;, IntlListFormatter::TYPE_OR); echo $formatter-\u0026gt;format([\u0026#39;rouge\u0026#39;, \u0026#39;bleu\u0026#39;, \u0026#39;vert\u0026#39;]); // \u0026#34;rouge, bleu ou vert\u0026#34; The class wraps ICU\u0026rsquo;s ListFormatter. Three types: TYPE_AND, TYPE_OR, TYPE_UNITS. Width constants control whether you get \u0026ldquo;and\u0026rdquo; or \u0026ldquo;\u0026amp;\u0026rdquo;. Oxford comma handling, locale-specific conjunction placement, all handled by ICU.\nFILTER_THROW_ON_FAILURE for filter_var() filter_var() returns false on validation failure, which produces the classic false vs null vs 0 ambiguity when you\u0026rsquo;re filtering untrusted input. A new flag changes that:\ntry { $email = filter_var($input, FILTER_VALIDATE_EMAIL, FILTER_THROW_ON_FAILURE); } catch (Filter\\FilterFailedException $e) { // explicitly invalid, not ambiguously false } The Filter\\FilterFailedException and Filter\\FilterException classes are new in 8.5. The flag can\u0026rsquo;t be combined with FILTER_NULL_ON_FAILURE: the behaviors are mutually exclusive.\nDeprecations that clean up years of technical debt The backtick operator (`command` as an alias for shell_exec()) is deprecated. It\u0026rsquo;s an obscure syntax that surprises anyone reading the code and is inconsistent with every other PHP function call.\nNon-canonical cast names ((boolean), (integer), (double), (binary)) are deprecated in favor of their short forms: (bool), (int), (float), (string). The long forms have been undocumented for years; 8.5 starts the formal removal.\nSemicolon-terminated case statements are deprecated:\n// deprecated switch ($x) { case 1; break; } // correct switch ($x) { case 1: break; } The semicolon form has been syntactically valid since PHP 4 but nobody uses it on purpose. It\u0026rsquo;s a typo PHP happened to accept.\n__sleep() and __wakeup() are deprecated in favor of __serialize() and __unserialize(), which return and receive arrays and compose correctly with inheritance. The old methods had messy semantics around property visibility.\nmax_memory_limit caps runaway allocations A new startup-only INI directive: max_memory_limit. It sets a ceiling that memory_limit can\u0026rsquo;t exceed at runtime. If a script calls ini_set('memory_limit', '10G') and max_memory_limit is 512M, PHP warns and caps the value.\nUseful in shared hosting environments, or anywhere you want to make sure a bug or a malicious payload can\u0026rsquo;t convince PHP to raise its own limit and eat the whole machine\u0026rsquo;s RAM.\nOpcache is always present In 8.5, Opcache is always compiled into the PHP binary and always loaded. The old situation (Opcache as a loadable extension that might or might not be present depending on build configuration) is gone.\nYou can still disable it: opcache.enable=0 works fine. What changes is the guarantee that the Opcache API (opcache_get_status(), opcache_invalidate(), etc.) is always available, regardless of how PHP was compiled. Any code that checks extension_loaded('opcache') before calling Opcache functions can drop the check.\n","permalink":"https://guillaumedelre.github.io/2026/01/04/php-8.5-the-pipe-operator-a-uri-library-and-a-lot-of-cleanup/","summary":"\u003cp\u003ePHP 8.5 shipped November 20th. Two features define this release: the pipe operator and the URI extension. They solve different problems, but both share the same motivation: making common operations less awkward to express.\u003c/p\u003e\n\u003ch2 id=\"the-pipe-operator\"\u003eThe pipe operator\u003c/h2\u003e\n\u003cp\u003eFunctional pipelines in PHP have always been a mess. Chaining transformations meant either nesting function calls inside out, or breaking them into intermediate variables:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// before — read right to left\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$result \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_sum\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003earray_map\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003earray_filter\u003c/span\u003e($strings, \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e)));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// or verbose but readable\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$filtered   \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_filter\u003c/span\u003e($strings, \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$lengths    \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_map\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e, $filtered);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$result     \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_sum\u003c/span\u003e($lengths);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// after — read left to right\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$result \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e $strings\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_filter\u003c/span\u003e(\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_map\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;strlen\u0026#39;\u003c/span\u003e, \u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003earray_sum\u003c/span\u003e(\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe \u003ccode\u003e|\u0026gt;\u003c/code\u003e operator passes the left-hand value into the right-hand expression. The \u003ccode\u003e?\u003c/code\u003e placeholder marks where it goes. Pipelines now read in the order operations happen: left to right, top to bottom.\u003c/p\u003e","title":"PHP 8.5: the pipe operator, a URI library, and a lot of cleanup"},{"content":"API Platform 4.2 arrived in September 2025. Three changes stand out: a JSON streamer for large collections that avoids buffering the entire response in memory, an ObjectMapper that replaces the manual wiring in stateOptions-based DTO flows, and autoconfiguration of #[ApiResource] without explicit service registration.\nJSON streamer for large collections The default Symfony serializer builds the full response in memory before writing it to the output. For a collection of 10,000 items, this means allocating a PHP array, serializing it to a string, and keeping both in memory until the response is flushed. At scale, this is the source of the OOM errors that force people to add pagination everywhere.\n4.2 adds a streaming JSON encoder that writes the response incrementally:\napi_platform: serializer: enable_json_streamer: true With streaming enabled, the response is written directly to the output buffer as each item is serialized. Memory usage stays roughly constant regardless of collection size. The trade-off: you cannot set response headers after streaming starts, and the HTTP status code must be committed before the first byte is written.\nObjectMapper replaces manual DTO wiring 3.1 introduced stateOptions with DoctrineOrmOptions for separating the API resource from the Doctrine entity. The provider received entity objects and the serializer mapped them to the DTO. This worked, but the mapping was implicit — the serializer used property names to match fields, and anything that did not match was either ignored or caused a normalization error.\n4.2 introduces ObjectMapper, a declarative mapping layer between entities and DTOs:\nuse Symfony\\Component\\ObjectMapper\\Attribute\\Map; #[Map(source: BookEntity::class)] class BookDto { public string $title; public string $authorName; } The #[Map] attribute tells ObjectMapper that BookDto can be populated from BookEntity. Field names are matched by convention; mismatches are declared explicitly at the property level:\nuse Symfony\\Component\\ObjectMapper\\Attribute\\Map; #[Map(source: BookEntity::class)] class BookDto { #[Map(source: \u0026#39;author.fullName\u0026#39;)] public string $authorName; } The dot notation traverses nested objects. The mapping runs before serialization and replaces the implicit property-matching behavior of the serializer. Unmapped fields raise an error at configuration time, not at runtime.\nObjectMapper works with stateOptions:\nuse ApiPlatform\\Doctrine\\Orm\\State\\Options; use ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\GetCollection; use Symfony\\Component\\ObjectMapper\\Attribute\\Map; #[ApiResource( operations: [ new GetCollection( stateOptions: new Options(entityClass: BookEntity::class), ), ] )] #[Map(source: BookEntity::class)] class BookDto {} The provider fetches BookEntity objects from Doctrine. ObjectMapper converts them to BookDto instances. The serializer writes the DTO. Three distinct steps, each with a clear contract.\nTypeInfo integration throughout the stack Symfony 7.1 introduced the TypeInfo component , a unified type introspection layer that understands union types, intersection types, generic collections, and nullable types across reflection, PHPDoc, and PHP 8.x syntax.\n4.2 replaces API Platform\u0026rsquo;s internal type resolution with TypeInfo. This affects filter schema generation, OpenAPI schema inference, and the serializer\u0026rsquo;s type coercion. The visible benefit is that types that previously generated incorrect or missing OpenAPI schemas — Collection\u0026lt;int, Book\u0026gt;, list\u0026lt;string\u0026gt;, intersection types — now produce accurate schemas without manual @ApiProperty annotations.\nAutoconfigure #[ApiResource] Before 4.2, adding #[ApiResource] to a class was sufficient for Hugo to discover it only if the class was in a path scanned by API Platform\u0026rsquo;s resource loader. Outside that path, you needed explicit service configuration.\n4.2 hooks into Symfony\u0026rsquo;s autoconfigure system. Any class tagged with #[ApiResource] is automatically registered as a resource regardless of its location, as long as it is in a directory covered by Symfony\u0026rsquo;s component scan. No config/services.yaml entry needed.\nFor Laravel, the equivalent uses Laravel\u0026rsquo;s service provider autoloading — Eloquent models with #[ApiResource] are picked up automatically without manual registration.\nDoctrine ExistsFilter The ExistsFilter constrains a collection by whether a nullable relation or field is set:\n#[ApiFilter(ExistsFilter::class, properties: [\u0026#39;publishedAt\u0026#39;])] class Book {} GET /books?exists[publishedAt]=true returns books where publishedAt is not null. exists[publishedAt]=false returns books where it is null.\n","permalink":"https://guillaumedelre.github.io/2025/09/18/api-platform-4.2-json-streamer-objectmapper-and-autoconfigure/","summary":"\u003cp\u003eAPI Platform 4.2 arrived in September 2025. Three changes stand out: a JSON streamer for large collections that avoids buffering the entire response in memory, an ObjectMapper that replaces the manual wiring in \u003ccode\u003estateOptions\u003c/code\u003e-based DTO flows, and autoconfiguration of \u003ccode\u003e#[ApiResource]\u003c/code\u003e without explicit service registration.\u003c/p\u003e\n\u003ch2 id=\"json-streamer-for-large-collections\"\u003eJSON streamer for large collections\u003c/h2\u003e\n\u003cp\u003eThe default Symfony serializer builds the full response in memory before writing it to the output. For a collection of 10,000 items, this means allocating a PHP array, serializing it to a string, and keeping both in memory until the response is flushed. At scale, this is the source of the OOM errors that force people to add pagination everywhere.\u003c/p\u003e","title":"API Platform 4.2: JSON streamer, ObjectMapper, and autoconfigure"},{"content":"When you run workloads on-premise, you can get away with almost no observability. You have SSH. You have top. You have someone who knows that the authentication service always spikes on Monday mornings. Institutional knowledge substitutes for instrumentation, and nobody budgets the time to replace it.\nThen you migrate to the cloud. The institutional knowledge doesn\u0026rsquo;t follow. The SSH access is gone or inconvenient. And for the first time, you\u0026rsquo;re staring at fourteen FrankenPHP containers with no idea what they\u0026rsquo;re actually doing.\nThat\u0026rsquo;s the moment you need metrics. Not eventually. Before the migration is done.\nThe problem with doing it properly The correct way to instrument a PHP service for Prometheus: add a client library, write counters and histograms around what you care about, expose a /metrics route, update the scrape config. For one service, that\u0026rsquo;s a reasonable afternoon. For fourteen services mid-migration, it\u0026rsquo;s a multi-sprint project that competes with everything else that needs to move.\nThe calculation is awkward. You need metrics to trust that the migration is going well. But adding metrics to everything before the migration means the migration takes longer. And the longer it takes, the more you need metrics to know where you stand.\nSomething had to give.\nWhat FrankenPHP carries without announcing it FrankenPHP is not a PHP runtime that happens to use Caddy as its web server. The relationship is inverted: Caddy is the server, and PHP is a Caddy module. Every HTTP request flows through Caddy before it reaches application code.\nCaddy ships with a Prometheus-compatible metrics endpoint built in. No plugin, no extra binary. Enable the admin API and it\u0026rsquo;s there.\nCADDY_GLOBAL_OPTIONS is a FrankenPHP environment variable that injects directives directly into Caddy\u0026rsquo;s global configuration block. Two lines are enough:\nenvironment: CADDY_GLOBAL_OPTIONS: | admin 0.0.0.0:2019 metrics admin 0.0.0.0:2019 binds the admin API to all network interfaces - the default is localhost-only, which is unreachable from a Prometheus container on the same network. metrics enables the endpoint.\nAfter that, every container responds to GET :2019/metrics with a full Prometheus payload. Request counts labeled by status code, latency histograms, active connections. No route added to the application. No composer require. No Dockerfile change.\nOne environment variable, added to each service definition in a single commit. Fourteen scrape targets, all producing data.\nA usable picture in Grafana The Prometheus scrape config lists every service by its container name:\nscrape_configs: - job_name: caddy metrics_path: /metrics static_configs: - targets: - authentication:2019 - content:2019 - media:2019 # all 14 services Grafana sits on top of Prometheus. The Caddy community dashboard gives you request rates, error rates, and latency percentiles per service, per endpoint, per status code. Within a day of the migration landing in the new environment, there was something meaningful to look at.\nThe data tier follows the same logic: exporters for PostgreSQL, Redis, and RabbitMQ scrape at the infrastructure level without touching application code. Community dashboards exist for all of them.\nWhat this baseline actually covers The HTTP metrics from Caddy are web server metrics, not application metrics. They answer: is this service receiving traffic, is it returning errors, how fast is it responding. The kind of questions you ask when something is broken and you need to triage in the dark.\nThey don\u0026rsquo;t answer: how many items were processed today, which background job is stuck, what is the business impact of this latency spike. For those you need application instrumentation, and that work still exists when you have specific things to measure.\nBut in a migration context, that distinction matters less than it sounds. The things that break during a cloud migration are mostly infrastructure problems: a service that can\u0026rsquo;t reach its database, a memory limit that was set too low, a queue consumer that stopped picking up messages. Those are exactly the things the baseline covers.\nGetting instrumentation right for business-level events can wait until the platform is stable. Getting enough visibility to know whether the migration succeeded cannot.\n","permalink":"https://guillaumedelre.github.io/2025/06/07/observability-on-frankenphp-containers-before-the-cloud-migration-was-done/","summary":"\u003cp\u003eWhen you run workloads on-premise, you can get away with almost no observability. You have SSH. You have \u003ccode\u003etop\u003c/code\u003e. You have someone who knows that the authentication service always spikes on Monday mornings. Institutional knowledge substitutes for instrumentation, and nobody budgets the time to replace it.\u003c/p\u003e\n\u003cp\u003eThen you migrate to the cloud. The institutional knowledge doesn\u0026rsquo;t follow. The SSH access is gone or inconvenient. And for the first time, you\u0026rsquo;re staring at fourteen FrankenPHP containers with no idea what they\u0026rsquo;re actually doing.\u003c/p\u003e","title":"Observability on FrankenPHP containers before the cloud migration was done"},{"content":"The setup seemed perfect. Point *.traefik.me at 127.0.0.1, download a wildcard certificate from the same domain, drop it into Traefik, and every local service gets a clean HTTPS URL with no IP in the address bar. No Let\u0026rsquo;s Encrypt rate limits, no mkcert to explain to teammates, no self-signed warnings to click through. Just https://myapp.traefik.me and a green padlock.\nThen in March 2025, Let\u0026rsquo;s Encrypt revoked the certificate. The wildcard cert for traefik.me is gone and it\u0026rsquo;s not coming back.\nWhat traefik.me was actually selling traefik.me is a wildcard DNS resolver. Type anything.traefik.me and it resolves to 127.0.0.1. Type anything.10.0.0.1.traefik.me and it resolves to 10.0.0.1. No account, no configuration, no infrastructure to maintain. The DNS part still works fine, by the way.\nThe certificate was the bonus: a wildcard cert for *.traefik.me that pyrou, the maintainer, generated with Let\u0026rsquo;s Encrypt and distributed at https://traefik.me/cert.pem and https://traefik.me/privkey.pem. It was convenient precisely because it was shared: download, drop into Traefik, done.\nSharing a private key is why it died.\nThe CA/Browser Forum Baseline Requirements, section 9.6.3, require subscribers to \u0026ldquo;maintain sole control\u0026rdquo; over their private key. Distributing it to anyone who visits a URL is the exact opposite of sole control. Let\u0026rsquo;s Encrypt sent a notice, blocked future issuance for the domain, and revoked the existing certificate. Pyrou confirmed the situation and recommended mkcert as an alternative. The project will live on as a DNS resolver only.\nThe cert had already been revoked twice before 2025. Third time was the last.\nsslip.io does the same thing, differently sslip.io is also a wildcard DNS resolver, with one difference: the IP is encoded in the hostname rather than resolved from a fallback. 10-0-0-1.sslip.io resolves to 10.0.0.1. myapp.192-168-1-10.sslip.io resolves to 192.168.1.10. IPv6 works too.\nThe infrastructure behind sslip.io is also more visible: three nameservers in Singapore, the US, and Poland, handling over 10,000 requests per second, with public monitoring. About 1,000 GitHub stars and active maintenance under the Apache 2.0 licence.\nStrip away the certificate story and the comparison is pretty straightforward:\ntraefik.me sslip.io DNS wildcard yes yes Fallback to 127.0.0.1 yes no IPv6 no yes Wildcard certificate yes revoked no Infrastructure opaque documented Project activity stalled active traefik.me\u0026rsquo;s only remaining advantage is the 127.0.0.1 fallback: URLs without an IP segment. That matters if you really want myapp.traefik.me instead of myapp.127-0-0-1.sslip.io. Whether that difference is worth the infrastructure uncertainty is a short conversation.\nmkcert fills the gap mkcert creates a local certificate authority, installs it in the system trust store and whatever browsers it finds, then issues certificates signed by that CA. Browsers see a trusted chain. No warning, no click-through, no \u0026ldquo;proceed anyway\u0026rdquo;.\nmkcert -install That\u0026rsquo;s the one-time setup. After that, generating a certificate is one command:\nmkcert \u0026#34;*.127-0-0-1.sslip.io\u0026#34; # produces _wildcard.127-0-0-1.sslip.io.pem # _wildcard.127-0-0-1.sslip.io-key.pem The limitation is that mkcert\u0026rsquo;s CA is local. Other machines on the network won\u0026rsquo;t trust it by default. For a solo dev setup that\u0026rsquo;s fine. For a shared team environment, you\u0026rsquo;d need to distribute the CA root, which is essentially the same operational problem traefik.me was trying to avoid, just smaller in scope.\nThe Traefik configuration The setup is the same regardless of which DNS service you pick. Traefik needs the certificate mounted as a volume and a static file provider pointing at a TLS configuration file.\n# traefik/config/tls.yml tls: certificates: - certFile: /certs/cert.pem keyFile: /certs/key.pem stores: default: defaultCertificate: certFile: /certs/cert.pem keyFile: /certs/key.pem The key practice: run Traefik in its own Compose project, separate from the services it routes to. Each service project connects to Traefik through a shared external network. Start and stop services independently without touching the reverse proxy.\nStart by creating the external network once:\ndocker network create traefik-public traefik/compose.yml - Traefik alone, owning the network:\nservices: traefik: image: traefik:v3 ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; volumes: - /var/run/docker.sock:/var/run/docker.sock - ./config:/etc/traefik/config - ./certs:/certs command: - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --providers.docker=true - --providers.docker.network=traefik-public - --providers.file.directory=/etc/traefik/config networks: - traefik-public networks: traefik-public: external: true Copy the mkcert output into ./certs/, rename to cert.pem and key.pem, then:\ndocker compose -f traefik/compose.yml up -d Traefik is up, listening on 80 and 443, watching Docker for new containers. Nothing is routed yet.\nwhoami/compose.yml - a service that joins the same network:\nservices: whoami: image: traefik/whoami labels: - \u0026#34;traefik.enable=true\u0026#34; - \u0026#34;traefik.http.routers.whoami.rule=Host(`whoami.127-0-0-1.sslip.io`)\u0026#34; - \u0026#34;traefik.http.routers.whoami.tls=true\u0026#34; - \u0026#34;traefik.http.routers.whoami.entrypoints=websecure\u0026#34; networks: - traefik-public networks: traefik-public: external: true docker compose -f whoami/compose.yml up -d Traefik detects the new container via the Docker provider, reads its labels, and adds the route. https://whoami.127-0-0-1.sslip.io responds immediately. Bring whoami down and the route disappears. Traefik keeps running without noticing.\nThe external: true declaration is the load-bearing line. Without it, Compose creates a project-scoped network: Traefik and whoami end up on different networks and can\u0026rsquo;t reach each other, even though both are running. The external network is the shared bus every service project must explicitly opt into.\nIf you prefer traefik.me URLs, replace the mkcert command and the host label:\nmkcert \u0026#34;*.traefik.me\u0026#34; - \u0026#34;traefik.http.routers.whoami.rule=Host(`whoami.traefik.me`)\u0026#34; The DNS fallback to 127.0.0.1 handles the rest.\nWhat the traefik.me story actually teaches The certificate distribution model was always fragile. A \u0026ldquo;public-private key pair\u0026rdquo; is a contradiction in terms. Every revocation was a warning that the next one could be permanent. Eventually it was.\nThe lesson isn\u0026rsquo;t specific to traefik.me. Any service that provides convenience by quietly removing a security boundary will eventually hit that boundary. mkcert is the right tool for this problem because it operates entirely within your own trust domain: you generate the CA, you install it, you issue the certificates. Nothing depends on a third party\u0026rsquo;s continued willingness to bend certificate issuance rules.\nsslip.io solves the DNS part cleanly. mkcert solves the TLS part cleanly. They compose well. The traefik.me setup was simpler, for a while. Until it wasn\u0026rsquo;t.\n","permalink":"https://guillaumedelre.github.io/2025/04/17/local-https-with-traefik-traefik.me-is-dead-long-live-sslip.io/","summary":"\u003cp\u003eThe setup seemed perfect. Point \u003ccode\u003e*.traefik.me\u003c/code\u003e at 127.0.0.1, download a wildcard certificate from the same domain, drop it into Traefik, and every local service gets a clean HTTPS URL with no IP in the address bar. No Let\u0026rsquo;s Encrypt rate limits, no \u003ccode\u003emkcert\u003c/code\u003e to explain to teammates, no self-signed warnings to click through. Just \u003ccode\u003ehttps://myapp.traefik.me\u003c/code\u003e and a green padlock.\u003c/p\u003e\n\u003cp\u003eThen in March 2025, Let\u0026rsquo;s Encrypt revoked the certificate. The wildcard cert for traefik.me is gone and it\u0026rsquo;s not coming back.\u003c/p\u003e","title":"Local HTTPS with Traefik: traefik.me is dead, long live sslip.io"},{"content":"API Platform 4.1 arrived in February 2025 with a batch of features that are less about new capabilities and more about making the existing ones production-ready. Strict query param validation gets a first-class property. OpenAPI gains a mechanism for splitting large APIs into separate specs. GraphQL gets the abuse prevention controls it was missing.\nStrict query parameter validation 3.3 introduced query parameter validation as opt-in. 3.4 deprecated the loose behavior. 4.1 formalizes it with a native strictQueryParameterValidation property on resources and operations: when set to true, unknown query parameters return 400.\nuse ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\Metadata\\QueryParameter; #[GetCollection( strictQueryParameterValidation: true, parameters: [ new QueryParameter(key: \u0026#39;utm_source\u0026#39;, required: false, schema: [\u0026#39;type\u0026#39; =\u0026gt; \u0026#39;string\u0026#39;]), new QueryParameter(key: \u0026#39;feature_flag\u0026#39;, required: false, schema: [\u0026#39;type\u0026#39; =\u0026gt; \u0026#39;string\u0026#39;]), ] )] class Book {} Declared parameters pass through; undeclared parameters are rejected. To disable strict validation on a specific operation when it is enabled at the resource level, set strictQueryParameterValidation: false on that operation.\nx-apiplatform-tag for multi-spec OpenAPI Large APIs often need multiple OpenAPI specs: one per team, one per API version, one internal and one public. Before 4.1, the generated spec was one document, and splitting it required post-processing or separate API Platform instances.\n4.1 adds an x-apiplatform-tag vendor extension (no trailing s). You tag operations with logical group names via the extensionProperties of an OpenAPI Operation object, then request the spec filtered to one or more groups:\nuse ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\OpenApi\\Factory\\OpenApiFactory; use ApiPlatform\\OpenApi\\Model\\Operation; #[GetCollection( openapi: new Operation( extensionProperties: [OpenApiFactory::API_PLATFORM_TAG =\u0026gt; [\u0026#39;public\u0026#39;, \u0026#39;v2\u0026#39;]] ) )] class Book {} Requesting /api/docs.json?filter_tags[]=public returns only the operations tagged public. The full spec is still available without a filter. Groups do not affect the actual API behavior — they are a documentation-layer concern only.\nThis makes it feasible to maintain one API Platform configuration while serving different spec views to different consumers: a public Swagger UI, a partner portal, and an internal tool that exposes admin endpoints.\nHTTP authentication in Swagger UI Before 4.1, the Swagger UI bundled with API Platform supported Bearer token authentication via its \u0026ldquo;Authorize\u0026rdquo; dialog. API Key and HTTP Basic authentication were not wired in.\n4.1 adds support for multiple security schemes in the generated OpenAPI document. Security schemes are added by decorating the OpenApiFactory and modifying the components.securitySchemes object of the spec. Each declared scheme then appears in Swagger UI\u0026rsquo;s \u0026ldquo;Authorize\u0026rdquo; dialog and is applied to requests made from the UI. This is a documentation and developer experience improvement — the actual authentication logic in your application is not affected.\nGraphQL query depth and complexity limits GraphQL\u0026rsquo;s recursive query structure makes it trivial to craft a query that is small in bytes but enormous in execution cost. Without limits, a nested query four levels deep across a many-to-many relation can hit the database hundreds of times.\n4.1 adds configurable depth and complexity limits:\napi_platform: graphql: max_query_depth: 10 max_query_complexity: 100 max_query_depth is the maximum nesting level. max_query_complexity assigns a cost to each field and rejects queries whose total cost exceeds the threshold. Queries that exceed either limit are rejected before execution with a 400 response.\nThere is no universally correct value for these limits — they depend on your schema shape and expected query patterns. The defaults are intentionally permissive to avoid breaking existing APIs on upgrade. Tightening them is a deliberate configuration choice.\nOperation-level output formats 4.0 and earlier configured accepted and returned content types at the API level. 4.1 lets you narrow this per operation:\nuse ApiPlatform\\Metadata\\GetCollection; #[GetCollection( outputFormats: [\u0026#39;jsonld\u0026#39; =\u0026gt; [\u0026#39;application/ld+json\u0026#39;]], inputFormats: [\u0026#39;json\u0026#39; =\u0026gt; [\u0026#39;application/json\u0026#39;]], )] class Book {} Operations that do not specify formats inherit the API-level configuration. This is useful for endpoints that need to return a specific format (a CSV export, a binary stream) without changing the defaults for the rest of the API.\n","permalink":"https://guillaumedelre.github.io/2025/02/28/api-platform-4.1-strict-query-params-multi-spec-openapi-and-graphql-limits/","summary":"\u003cp\u003eAPI Platform 4.1 arrived in February 2025 with a batch of features that are less about new capabilities and more about making the existing ones production-ready. Strict query param validation gets a first-class property. OpenAPI gains a mechanism for splitting large APIs into separate specs. GraphQL gets the abuse prevention controls it was missing.\u003c/p\u003e\n\u003ch2 id=\"strict-query-parameter-validation\"\u003eStrict query parameter validation\u003c/h2\u003e\n\u003cp\u003e3.3 introduced query parameter validation as opt-in. 3.4 deprecated the loose behavior. 4.1 formalizes it with a native \u003ccode\u003estrictQueryParameterValidation\u003c/code\u003e property on resources and operations: when set to \u003ccode\u003etrue\u003c/code\u003e, unknown query parameters return 400.\u003c/p\u003e","title":"API Platform 4.1: strict query params, multi-spec OpenAPI, and GraphQL limits"},{"content":"The search box on the media library returned results in 800 milliseconds on staging. Production had forty times more rows. The query plan showed a sequential scan: no index involved, no way to fix it with a standard B-tree. The product team also wanted multi-word search: type \u0026ldquo;interview president\u0026rdquo;, get results containing both words. A LIKE query with wildcards has no clean way to express that without multiple independent conditions, each requiring its own scan.\nPostgreSQL has had built-in full-text search for over fifteen years. The platform was already on PostgreSQL. The catch: the project uses Doctrine ORM, and Doctrine doesn\u0026rsquo;t natively know what a tsvector is.\nA community library, postgresql-for-doctrine, covers part of that gap. It registers basic DQL functions like TO_TSQUERY, TO_TSVECTOR, and the @@ match operator as separate atomic pieces. The foundation was there. Three things still had to be built on top.\nThe type Doctrine has never seen PostgreSQL\u0026rsquo;s full-text search is built around two types: tsvector (a pre-processed list of normalized tokens) and tsquery (a search expression). You maintain a tsvector column, index it with GIN, and query with the @@ match operator.\nDoctrine\u0026rsquo;s DBAL ships no tsvector type. Declaring #[ORM\\Column(type: 'tsvector')] without registering it first throws a UnknownColumnTypeException. The fix is a custom DBAL type:\nclass TsVector extends Type { final public const string DBAL_TYPE = \u0026#39;tsvector\u0026#39;; public function getSQLDeclaration(array $column, AbstractPlatform $platform): string { return self::DBAL_TYPE; } public function getName(): string { return self::DBAL_TYPE; } public function convertToDatabaseValueSQL(string $sqlExpr, AbstractPlatform $platform): string { return sprintf(\u0026#34;to_tsvector(\u0026#39;simple\u0026#39;, %s)\u0026#34;, $sqlExpr); } public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): mixed { if (is_array($value) \u0026amp;\u0026amp; isset($value[\u0026#39;data\u0026#39;])) { return $value[\u0026#39;data\u0026#39;]; } return is_string($value) ? $value : null; } public function getMappedDatabaseTypes(AbstractPlatform $platform): array { return [self::DBAL_TYPE]; } } The interesting method is convertToDatabaseValueSQL(). Doctrine calls it to wrap the SQL placeholder before the value reaches the database. The written value automatically becomes to_tsvector('simple', ?) at the DBAL boundary with no extra step needed on the calling side.\nRegister the type in doctrine.yaml, then map the column on the entity:\ndoctrine: dbal: types: tsvector: App\\Doctrine\\DBAL\\Types\\TsVector #[ORM\\Column(type: \u0026#39;tsvector\u0026#39;, nullable: true)] protected ?string $textSearch = null; PHP-side, the value is a plain string. The conversion to a proper tsvector happens invisibly at the DBAL layer.\nWe used the 'simple' dictionary, which tokenizes on whitespace and punctuation without language-specific stemming. The platform handles multiple languages, and French stemming rules would break Spanish. Simple is good enough for phonetics.\nKeeping the column current A tsvector column is derived data: it has to stay in sync with the source fields whenever the entity changes. A Doctrine event listener handles that:\n#[AsDoctrineListener(event: Events::prePersist)] #[AsDoctrineListener(event: Events::preUpdate)] class MediaTsVectorSubscriber { public function prePersist(PrePersistEventArgs $event): void { if (!$event-\u0026gt;getObject() instanceof Media) { return; } $this-\u0026gt;updateTextSearch($event-\u0026gt;getObject()); } public function preUpdate(PreUpdateEventArgs $event): void { if (!$event-\u0026gt;getObject() instanceof Media) { return; } $this-\u0026gt;updateTextSearch($event-\u0026gt;getObject()); } private function updateTextSearch(Media $entity): void { $entity-\u0026gt;setTextSearch( sprintf(\u0026#39;%s %s\u0026#39;, $entity-\u0026gt;getTitle(), $entity-\u0026gt;getCaption()) ); } } Before every persist and update, the subscriber concatenates the fields that should be searchable into textSearch. Doctrine flushes the combined string, the DBAL type wraps it in to_tsvector('simple', ...), and PostgreSQL stores the tokenized form.\nOne subtlety: the PHP-side value is \u0026quot;title caption\u0026quot;, not the actual tsvector output. The database shows 'caption' 'title' (sorted tokens), but the entity holds a plain string. That\u0026rsquo;s expected: the conversion is a DBAL responsibility, not a PHP one. It can be confusing to debug until you remember where the boundary is.\nExtending DQL with FTS operators Doctrine\u0026rsquo;s DQL covers common SQL operations, but anything PostgreSQL-specific is out of scope. That\u0026rsquo;s where postgresql-for-doctrine starts: it registers TO_TSQUERY, TO_TSVECTOR, and TSMATCH as individual DQL functions. Writing a full-text query in DQL without it would mean dropping to native SQL entirely.\nThe library\u0026rsquo;s functions are atomic, though. Each maps to one SQL call. Expressing a full match check in DQL looks like TSMATCH(o.textSearch, TO_TSQUERY(:term)). Readable enough, but the team wanted something more compact: a single DQL function that encodes both the match operator and the query type, including websearch_to_tsquery, which postgresql-for-doctrine didn\u0026rsquo;t ship.\nThe solution is custom DQL functions via FunctionNode. You parse the DQL syntax, then emit SQL. All FTS functions share the same two-argument signature, so an abstract base class handles parsing:\nabstract class TsFunction extends FunctionNode { public PathExpression|Node|null $ftsField = null; public PathExpression|Node|null $queryString = null; public function parse(Parser $parser): void { $parser-\u0026gt;match(TokenType::T_IDENTIFIER); $parser-\u0026gt;match(TokenType::T_OPEN_PARENTHESIS); $this-\u0026gt;ftsField = $parser-\u0026gt;StringPrimary(); $parser-\u0026gt;match(TokenType::T_COMMA); $this-\u0026gt;queryString = $parser-\u0026gt;StringPrimary(); $parser-\u0026gt;match(TokenType::T_CLOSE_PARENTHESIS); } } Each concrete class implements getSql() to emit its PostgreSQL expression:\n// e.textSearch @@ websearch_to_tsquery(\u0026#39;simple\u0026#39;, :term) class TsWebsearchQueryFunction extends TsFunction { public function getSql(SqlWalker $sqlWalker): string { return $this-\u0026gt;ftsField-\u0026gt;dispatch($sqlWalker) .\u0026#34; @@ websearch_to_tsquery(\u0026#39;simple\u0026#39;, \u0026#34; .$this-\u0026gt;queryString-\u0026gt;dispatch($sqlWalker).\u0026#39;)\u0026#39;; } } // ts_rank(e.textSearch, to_tsquery(:term)) for relevance ordering class TsRankFunction extends TsFunction { public function getSql(SqlWalker $sqlWalker): string { return \u0026#39;ts_rank(\u0026#39; .$this-\u0026gt;ftsField-\u0026gt;dispatch($sqlWalker) .\u0026#39;, to_tsquery(\u0026#39;.$this-\u0026gt;queryString-\u0026gt;dispatch($sqlWalker).\u0026#39;))\u0026#39;; } } doctrine: orm: entity_managers: default: dql: string_functions: tswebsearchquery: App\\Doctrine\\ORM\\Query\\AST\\Functions\\TsWebsearchQueryFunction tsrank: App\\Doctrine\\ORM\\Query\\AST\\Functions\\TsRankFunction tsquery: App\\Doctrine\\ORM\\Query\\AST\\Functions\\TsQueryFunction tsplainquery: App\\Doctrine\\ORM\\Query\\AST\\Functions\\TsPlainQueryFunction websearch_to_tsquery is the right choice for user-facing search: spaces become AND, quoted strings become phrases, -word excludes a term. No need to teach users to type interview \u0026amp; president. It was added in PostgreSQL 11. On older versions, plainto_tsquery is the closest equivalent.\nThe API Platform filter and the GIN index With the DQL functions registered, the API Platform filter is straightforward. A custom AbstractFilter calls the DQL function directly in the QueryBuilder:\nclass TextSearchFilter extends AbstractFilter { protected function filterProperty( string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = [] ): void { if (\u0026#39;textSearch\u0026#39; !== $property || empty($value)) { return; } $queryBuilder -\u0026gt;andWhere(\u0026#39;tswebsearchquery(o.textSearch, :value) = true\u0026#39;) -\u0026gt;setParameter(\u0026#39;:value\u0026#39;, $value); } public function getDescription(string $resourceClass): array { return []; } } Apply it on the entity alongside the index declaration:\n#[ORM\\Index( columns: [\u0026#39;text_search\u0026#39;], name: \u0026#39;media_text_search_idx_gin\u0026#39;, options: [\u0026#39;USING\u0026#39; =\u0026gt; \u0026#39;gin (text_search)\u0026#39;] )] #[ApiFilter(TextSearchFilter::class, properties: [\u0026#39;textSearch\u0026#39; =\u0026gt; \u0026#39;partial\u0026#39;])] class Media { // ... #[ORM\\Column(type: \u0026#39;tsvector\u0026#39;, nullable: true)] protected ?string $textSearch = null; } The USING gin option is non-negotiable. A standard B-tree index on a tsvector column is useless: PostgreSQL can\u0026rsquo;t use it for @@ queries. GIN (Generalized Inverted Index) works differently: it indexes each token individually, so lookups by any token are O(log n) rather than O(n). Without it, you\u0026rsquo;ve built a fast-looking system that still does a full table scan.\nA GET /media?textSearch=interview+president now hits the GIN index and returns in single-digit milliseconds regardless of table size.\nWhat the split actually looked like The library covered the low-level atomic functions. The custom code covered the gaps: a tsvector DBAL type the library didn\u0026rsquo;t provide, convenience DQL wrappers that combined @@ and websearch_to_tsquery into a single call, and the application-specific glue connecting it all to Doctrine\u0026rsquo;s event system and API Platform. Nothing needed to drop to a native query.\nThe split is worth noting in general: postgresql-for-doctrine gives you the atomic PostgreSQL building blocks, but you still need to compose them into something the rest of the codebase can use without thinking about it. The FunctionNode pattern and the convertToDatabaseValueSQL() hook are the two extension points that make that composition clean. Both are worth knowing about, regardless of what library you start from.\n","permalink":"https://guillaumedelre.github.io/2025/02/10/postgresql-full-text-search-through-doctrine-without-a-line-of-raw-sql/","summary":"\u003cp\u003eThe search box on the media library returned results in 800 milliseconds on staging. Production had forty times more rows. The query plan showed a sequential scan: no index involved, no way to fix it with a standard B-tree. The product team also wanted multi-word search: type \u0026ldquo;interview president\u0026rdquo;, get results containing both words. A \u003ccode\u003eLIKE\u003c/code\u003e query with wildcards has no clean way to express that without multiple independent conditions, each requiring its own scan.\u003c/p\u003e","title":"PostgreSQL full-text search through Doctrine, without a line of raw SQL"},{"content":"PHP 8.4 released November 21st. Property hooks are the feature. Everything else, and there\u0026rsquo;s quite a bit of it, is secondary.\nProperty hooks For twenty years, if you wanted behavior on property access in PHP you had to write getters and setters:\nclass User { private string $_name; public function getName(): string { return $this-\u0026gt;_name; } public function setName(string $name): void { $this-\u0026gt;_name = strtoupper($name); } } PHP 8.4 adds hooks directly on the property:\nclass User { public string $name { set(string $name) { $this-\u0026gt;name = strtoupper($name); } } } You can define get and set hooks independently. A property with only a get hook is computed on access:\nclass Circle { public float $area { get =\u0026gt; M_PI * $this-\u0026gt;radius ** 2; } public function __construct(public float $radius) {} } No backing storage, no explicit getter method, full IDE support. Interfaces can declare properties with hooks too, which means contracts can now specify behavior on property access, something that was flat-out impossible before.\nAsymmetric visibility A lighter option for when you just want public read, private write:\nclass Version { public private(set) string $value = \u0026#39;1.0.0\u0026#39;; } $v = new Version(); echo $v-\u0026gt;value; // works $v-\u0026gt;value = \u0026#39;2.0\u0026#39;; // Error Kills the private $x + public getX() pattern for read-only public properties without needing full readonly semantics.\narray_find() and friends $first = array_find($users, fn($u) =\u0026gt; $u-\u0026gt;isActive()); $any = array_any($users, fn($u) =\u0026gt; $u-\u0026gt;isPremium()); $all = array_all($users, fn($u) =\u0026gt; $u-\u0026gt;isVerified()); These have been in every other language\u0026rsquo;s standard library for decades. In PHP, you had to use array_filter() + index access or write a manual loop. They exist now: array_find(), array_find_key(), array_any(), array_all().\nInstantiation without extra parentheses // before (new MyClass())-\u0026gt;method(); // after new MyClass()-\u0026gt;method(); A syntax restriction that was always annoying and never justified is gone.\nLazy objects Objects whose initialization is deferred until first property access:\n$user = $reflector-\u0026gt;newLazyProxy(fn() =\u0026gt; $repository-\u0026gt;find($id)); // No database call yet $user-\u0026gt;name; // Now the proxy initializes The direct audience is framework ORM and DI container authors, not application developers. But the effect shows up in every app that uses Doctrine or Symfony: lazy loading implemented at the language level rather than through code generation.\nPHP 8.4 is a language that barely resembles the PHP 5 most of us started with. Property hooks in particular: they\u0026rsquo;re not a workaround, they\u0026rsquo;re a design feature.\n#[\\Deprecated] for your own code PHP has emitted deprecation notices for built-in functions for years. 8.4 lets you wire the same mechanism into your own code:\nclass ApiClient { #[\\Deprecated( message: \u0026#39;Use fetchJson() instead\u0026#39;, since: \u0026#39;2.0\u0026#39;, )] public function get(string $url): string { ... } } Calling a deprecated method now emits E_USER_DEPRECATED, just like calling mysql_connect(). IDEs pick it up, static analyzers flag it, the error log captures it. Before this, the only option was a @deprecated PHPDoc comment: fine for IDEs, completely invisible to the engine.\nBcMath\\Number makes arbitrary precision usable The bcmath functions have been in PHP since forever, but their procedural API makes chaining anything painful. 8.4 adds BcMath\\Number, an object wrapper with operator overloading:\n$a = new BcMath\\Number(\u0026#39;10.5\u0026#39;); $b = new BcMath\\Number(\u0026#39;3.2\u0026#39;); $result = $a + $b; // BcMath\\Number(\u0026#39;13.7\u0026#39;) $result = $a * $b - new BcMath\\Number(\u0026#39;1\u0026#39;); echo $result; // 32.6 The +, -, *, /, **, % operators all work. The object is immutable. Scale propagates automatically through operations. Financial calculations, which used to mean chains of bcadd(bcmul(...), ...), now just read like arithmetic.\nNew procedural functions complete the picture: bcceil(), bcfloor(), bcround(), bcdivmod().\nRoundingMode enum replaces PHP_ROUND_* constants round() has always taken a $mode int from a set of PHP_ROUND_* constants. 8.4 replaces that with a RoundingMode enum with cleaner names and four additional modes that weren\u0026rsquo;t available before:\nround(2.5, mode: RoundingMode::HalfAwayFromZero); // 3 round(2.5, mode: RoundingMode::HalfTowardsZero); // 2 round(2.5, mode: RoundingMode::HalfEven); // 2 (banker\u0026#39;s rounding) round(2.5, mode: RoundingMode::HalfOdd); // 3 // The four new modes (only available via the enum) round(2.3, mode: RoundingMode::TowardsZero); // 2 round(2.7, mode: RoundingMode::AwayFromZero); // 3 round(2.3, mode: RoundingMode::PositiveInfinity); // 3 round(2.3, mode: RoundingMode::NegativeInfinity); // 2 The old PHP_ROUND_* constants still work. The enum is the path forward.\nMultibyte string functions that should have existed mb_trim(), mb_ltrim(), mb_rtrim(): trim functions that respect multibyte character boundaries, not just ASCII whitespace. Also new: mb_ucfirst() and mb_lcfirst() for proper title-casing of multibyte strings.\n$s = \u0026#34;\\u{200B}hello\\u{200B}\u0026#34;; // Zero-width spaces echo mb_trim($s); // \u0026#34;hello\u0026#34; echo mb_ucfirst(\u0026#39;über\u0026#39;); // \u0026#34;Über\u0026#34; These fill gaps that have been sitting there since mbstring was introduced.\nrequest_parse_body() for non-POST requests PHP automatically parses application/x-www-form-urlencoded and multipart/form-data into $_POST and $_FILES, but only for POST requests. PATCH and PUT requests with the same content types needed manual parsing with file_get_contents('php://input') and custom code.\n// Inside a PATCH handler [$_POST, $_FILES] = request_parse_body(); The function returns a tuple. Same parsing logic PHP uses for POST, now available for any HTTP method.\nA new DOM API that follows the spec The existing DOMDocument API was built on an older DOM level 3 spec with PHP-specific quirks layered on top. 8.4 adds a parallel Dom\\ namespace that implements the WHATWG Living Standard:\n$doc = Dom\\HTMLDocument::createFromString(\u0026#39;\u0026lt;p class=\u0026#34;lead\u0026#34;\u0026gt;Hello\u0026lt;/p\u0026gt;\u0026#39;); $p = $doc-\u0026gt;querySelector(\u0026#39;p\u0026#39;); echo $p-\u0026gt;classList; // \u0026#34;lead\u0026#34; echo $p-\u0026gt;id; // \u0026#34;\u0026#34; $doc2 = Dom\\HTMLDocument::createFromFile(\u0026#39;page.html\u0026#39;); Dom\\HTMLDocument parses HTML5 correctly, tag soup included. Dom\\XMLDocument handles strict XML. The new classes are strict about types, return proper node types, and expose modern properties like classList, id, className. The old DOMDocument stays, unchanged, for backward compatibility.\nPDO gets driver-specific subclasses PDO::connect() and direct instantiation now return driver-specific subclasses when available:\n$pdo = PDO::connect(\u0026#39;mysql:host=localhost;dbname=test\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;pass\u0026#39;); // $pdo is now a Pdo\\Mysql instance $pdo = new Pdo\\Pgsql(\u0026#39;pgsql:host=localhost;dbname=test\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;pass\u0026#39;); Each driver subclass (Pdo\\Mysql, Pdo\\Pgsql, Pdo\\Sqlite, Pdo\\Firebird, Pdo\\Odbc, Pdo\\DbLib) can expose driver-specific methods without polluting the base PDO interface. Doctrine, Laravel, and similar ORMs can now type-hint against the specific driver class when they need driver-specific behavior.\nOpenSSL gets modern key support openssl_pkey_new() and related functions now support Curve25519 and Curve448, the modern elliptic curves that have replaced older NIST curves in most security recommendations:\n$key = openssl_pkey_new([\u0026#39;curve_name\u0026#39; =\u0026gt; \u0026#39;ed25519\u0026#39;, \u0026#39;private_key_type\u0026#39; =\u0026gt; OPENSSL_KEYTYPE_EC]); $details = openssl_pkey_get_details($key); x25519 and x448 for key exchange, ed25519 and ed448 for signatures. All four now work with openssl_sign() and openssl_verify().\nPCRE: variable-length lookbehind The bundled PCRE2 library update (10.44) brings variable-length lookbehind assertions, something Perl and Python regex engines had and PHP couldn\u0026rsquo;t do:\n// Match \u0026#34;bar\u0026#34; only when preceded by \u0026#34;foo\u0026#34; or \u0026#34;foooo\u0026#34; preg_match(\u0026#39;/(?\u0026lt;=foo+)bar/\u0026#39;, \u0026#39;foooobar\u0026#39;, $matches); Lookbehind assertions used to require a fixed-width pattern. Now they can match patterns of variable length. The r modifier (PCRE2_EXTRA_CASELESS_RESTRICT) is also new: it prevents mixing ASCII and non-ASCII characters in case-insensitive matches, closing a class of Unicode confusion attacks.\nDateTime gets microseconds and timestamp factory $dt = DateTimeImmutable::createFromTimestamp(1700000000.5); echo $dt-\u0026gt;getMicrosecond(); // 500000 $with_micros = $dt-\u0026gt;setMicrosecond(123456); createFromTimestamp() accepts a float for sub-second precision. getMicrosecond() and setMicrosecond() round out the API for the microsecond component that\u0026rsquo;s been inside DateTime internally but inaccessible directly.\nfpow() for IEEE 754 compliance pow(0, -2) in PHP has historically returned an implementation-defined value. 8.4 deprecates pow() with a zero base and negative exponent and introduces fpow(), which strictly follows IEEE 754: fpow(0, -2) returns INF, as the standard defines:\necho fpow(2.0, 3.0); // 8.0 echo fpow(0.0, -1.0); // INF echo fpow(-1.0, INF); // 1.0 Worth knowing in any code doing mathematical computations where IEEE compliance matters.\nThe cost of bcrypt goes up The default cost for password_hash() with PASSWORD_BCRYPT went from 10 to 12. This hits any code calling password_hash($pass, PASSWORD_BCRYPT) without an explicit cost. The goal is to keep the default roughly \u0026ldquo;a few hundred milliseconds on modern hardware\u0026rdquo; as hardware gets faster.\nIf you store bcrypt hashes and upgrade to 8.4, existing hashes stay valid: password_verify() reads the cost from the hash itself. New hashes use cost 12. password_needs_rehash() returns true for old hashes if you pass ['cost' =\u0026gt; 12], so you can upgrade them on next login.\nDeprecations that matter Implicitly nullable parameters are deprecated. If a parameter has a default of null, the type has to say so explicitly:\n// Deprecated in 8.4 function foo(string $s = null) {} // Correct function foo(?string $s = null) {} function foo(string|null $s = null) {} trigger_error() with E_USER_ERROR is deprecated: replace it with an exception or exit(). The E_USER_ERROR level was always an awkward hybrid between a recoverable error and a fatal one, and nobody was sure which.\nlcg_value() is deprecated too. Use Random\\Randomizer::getFloat() instead. The LCG generator had poor randomness properties and no seeding control.\n","permalink":"https://guillaumedelre.github.io/2025/01/05/php-8.4-property-hooks-and-the-end-of-the-getter/setter-ceremony/","summary":"\u003cp\u003ePHP 8.4 released November 21st. Property hooks are the feature. Everything else, and there\u0026rsquo;s quite a bit of it, is secondary.\u003c/p\u003e\n\u003ch2 id=\"property-hooks\"\u003eProperty hooks\u003c/h2\u003e\n\u003cp\u003eFor twenty years, if you wanted behavior on property access in PHP you had to write getters and setters:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eprivate\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e $_name;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003egetName\u003c/span\u003e()\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e { \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e $this\u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003e_name\u003c/span\u003e; }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esetName\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e $name)\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003evoid\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        $this\u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003e_name\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estrtoupper\u003c/span\u003e($name);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003ePHP 8.4 adds hooks directly on the property:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e $name {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eset\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e $name) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            $this\u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estrtoupper\u003c/span\u003e($name);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eYou can define \u003ccode\u003eget\u003c/code\u003e and \u003ccode\u003eset\u003c/code\u003e hooks independently. A property with only a \u003ccode\u003eget\u003c/code\u003e hook is computed on access:\u003c/p\u003e","title":"PHP 8.4: property hooks and the end of the getter/setter ceremony"},{"content":"API Platform 4.0 shipped nine days after 3.4, in late September 2024. The version number is honest: there is no new architecture, and the migration from 3.4 is short if you resolved the deprecations. What makes this a major is the scope change — API Platform is no longer a Symfony-only framework — and one opinionated default that reverses six years of PUT behavior.\nLaravel as a first-class target Since its first release, API Platform was built on Symfony. The HTTP layer, metadata, serializer, and Doctrine bridge all assumed Symfony\u0026rsquo;s container, event dispatcher, and request lifecycle. Laravel users could run API Platform through a thin adapter, but filters, security, and Doctrine integration did not work on Eloquent.\n4.0 ships a dedicated Laravel bridge. It maps API Platform\u0026rsquo;s state layer onto Laravel\u0026rsquo;s request lifecycle, integrates with Eloquent models directly, and wires into Laravel\u0026rsquo;s authorization system:\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Get; use ApiPlatform\\Metadata\\GetCollection; use Illuminate\\Database\\Eloquent\\Model; #[ApiResource( operations: [new GetCollection(), new Get()] )] class Book extends Model { protected $fillable = [\u0026#39;title\u0026#39;, \u0026#39;author\u0026#39;]; } Authorization uses Laravel policies and gates rather than Symfony\u0026rsquo;s security voters. Operations expose a dedicated policy parameter that maps to a policy method name:\n#[Get(policy: \u0026#39;view\u0026#39;)] class Book extends Model {} API Platform maps the policy value to Laravel\u0026rsquo;s Gate::allows() with the model instance. Policies can also be auto-detected: if a model has a registered policy class, API Platform infers the correct method (view, viewAny, create, update, delete) based on the operation type. Filters for Eloquent collections cover the same ground as their Doctrine counterparts: PartialSearchFilter, EqualsFilter, RangeFilter, OrderFilter, DateFilter, and search variants (StartSearchFilter, EndSearchFilter). Pagination, sorting, and validation work through Laravel\u0026rsquo;s native mechanisms.\nThis is not a compatibility shim. The Laravel bridge is maintained alongside the Symfony bridge and is covered by the same test suite. Projects using either framework get the same resource definition API.\nPUT removed from default operations Since API Platform 1.0, #[ApiResource] without an explicit operations array generated CRUD operations including PUT. The PUT handler updated existing resources and, after 3.1, could also create them via allowCreate: true.\n4.0 removes PUT from the default set. #[ApiResource] now generates GET, POST, PATCH, and DELETE. To use PUT, you must declare it explicitly:\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Put; #[ApiResource( operations: [ // ... other operations new Put(), ] )] class Book {} The motivation is semantic clarity. PATCH replaces PUT for most partial-update use cases. PUT\u0026rsquo;s semantics — replace the entire resource representation — are rarely what an API actually implements, but the default made it appear in every API unless actively removed. Making PUT opt-in aligns the defaults with how HTTP semantics are actually used in practice.\nPHP 8.2 minimum 4.0 drops PHP 8.0 and 8.1. PHP 8.2 is the new minimum. The readonly class syntax, AllowDynamicProperties, and DNF1 types introduced in 8.2 are available throughout the codebase. No specific 8.2 feature is load-bearing for 4.0 — the version bump is primarily about dropping the older maintenance burden.\nSymfony 6.4+ and Doctrine ORM 2.17+ minimum On the Symfony side, 4.0 requires Symfony 6.4 or 7.x and Doctrine ORM 2.17 or 3.x. Both were already supported in 3.4. The migration from 3.4 to 4.0 on the Symfony track is: resolve 3.4 deprecations, verify you are on Symfony 6.4+ and ORM 2.17+, then upgrade. No new migration work is required if those are already in place.\nWhat 4.0 is not 4.0 is not a new architecture. The state providers, processors, and resource metadata model from 3.0 are unchanged. The Laravel bridge adds a new execution context but does not change how resources or operations are declared. The split is intentional: if 3.0 was the \u0026ldquo;what\u0026rdquo;, 4.0 is the \u0026ldquo;where\u0026rdquo;.\nDisjunctive Normal Form types: intersection types combined with union, like (A\u0026amp;B)|null.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","permalink":"https://guillaumedelre.github.io/2024/09/27/api-platform-4.0-laravel-support-and-put-rethought/","summary":"\u003cp\u003eAPI Platform 4.0 shipped nine days after 3.4, in late September 2024. The version number is honest: there is no new architecture, and the migration from 3.4 is short if you resolved the deprecations. What makes this a major is the scope change — API Platform is no longer a Symfony-only framework — and one opinionated default that reverses six years of PUT behavior.\u003c/p\u003e\n\u003ch2 id=\"laravel-as-a-first-class-target\"\u003eLaravel as a first-class target\u003c/h2\u003e\n\u003cp\u003eSince its first release, API Platform was built on Symfony. The HTTP layer, metadata, serializer, and Doctrine bridge all assumed Symfony\u0026rsquo;s container, event dispatcher, and request lifecycle. Laravel users could run API Platform through a thin adapter, but filters, security, and Doctrine integration did not work on Eloquent.\u003c/p\u003e","title":"API Platform 4.0: Laravel support and PUT rethought"},{"content":"API Platform 3.4 landed in September 2024 as the last minor before the 4.0 jump. The headline feature is BackedEnum as full resources — not just a typed field, but an enum that is itself an API endpoint.\nBackedEnum as API resources Since PHP 8.1, BackedEnum classes have a fixed set of cases with string or integer backing values. API Platform 3.4 lets you put #[ApiResource] directly on a BackedEnum:\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\GetCollection; #[ApiResource( operations: [new GetCollection()] )] enum BookStatus: string { case Draft = \u0026#39;draft\u0026#39;; case Published = \u0026#39;published\u0026#39;; case Archived = \u0026#39;archived\u0026#39;; } A GET /book_statuses endpoint returns the list of cases. Each case is serialized with its name and value. The endpoint is read-only — enums are immutable by nature.\nThis is mostly useful for frontend consumers that want a machine-readable list of valid values without hardcoding them. The alternative was a custom controller or a dedicated DTO resource listing the enum values manually.\nBackedEnumFilter The companion to enum resources is BackedEnumFilter, a new filter for Doctrine collections that constrains a query by a BackedEnum property:\nuse ApiPlatform\\Doctrine\\Orm\\Filter\\BackedEnumFilter; use ApiPlatform\\Metadata\\ApiFilter; use ApiPlatform\\Metadata\\ApiResource; #[ApiResource] #[ApiFilter(BackedEnumFilter::class, properties: [\u0026#39;status\u0026#39;])] class Book { public BookStatus $status; } GET /books?status=published filters the collection to books where status equals BookStatus::Published. Invalid enum values return a 400 response. Before this filter, you had to either write a custom filter or use SearchFilter and validate the value manually.\nSecurity expressions on parameters 3.3 added security to links and properties. 3.4 extends this to query parameters. A parameter can declare a security expression that controls whether it is accepted at all:\nuse ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\Metadata\\QueryParameter; #[GetCollection( parameters: [ new QueryParameter( key: \u0026#39;includeDeleted\u0026#39;, security: \u0026#34;is_granted(\u0026#39;ROLE_ADMIN\u0026#39;)\u0026#34; ), ] )] class Book {} When the security expression is false, the parameter is rejected with a 403, not silently ignored. This is more explicit than checking the user\u0026rsquo;s role inside the provider after receiving the parameter.\nDBAL 4 support added 3.4 adds support for Doctrine DBAL 4, which ships with type system changes that affect how custom types and platform-specific SQL work. The Doctrine Orm filters and query extensions in API Platform were updated to work with the new DBAL 4 type API.\nBoth DBAL 3 (^3.4.0) and DBAL 4 are supported simultaneously in 3.4. This is the release to upgrade to if you want to adopt DBAL 4 while staying on a stable API Platform 3.x branch.\nQuery parameter validator deprecated 3.3 added the strict query parameter validator as an opt-in. 3.4 deprecates the old behavior (unknown parameters silently ignored) in preparation for making strict validation the default in 4.0. Projects that relied on pass-through query parameters have one more release to declare them explicitly.\nLast stop before 4.0 3.4 is the last 3.x release with new features. Anything 3.x that was deprecated by 3.4 is gone in 4.0. The migration path from 3.4 to 4.0 is intentionally short: resolve the deprecations, then upgrade.\n","permalink":"https://guillaumedelre.github.io/2024/09/18/api-platform-3.4-backedenum-as-resources-and-dbal-4-support/","summary":"\u003cp\u003eAPI Platform 3.4 landed in September 2024 as the last minor before the 4.0 jump. The headline feature is BackedEnum as full resources — not just a typed field, but an enum that is itself an API endpoint.\u003c/p\u003e\n\u003ch2 id=\"backedenum-as-api-resources\"\u003eBackedEnum as API resources\u003c/h2\u003e\n\u003cp\u003eSince PHP 8.1, BackedEnum classes have a fixed set of cases with string or integer backing values. API Platform 3.4 lets you put \u003ccode\u003e#[ApiResource]\u003c/code\u003e directly on a BackedEnum:\u003c/p\u003e","title":"API Platform 3.4: BackedEnum as resources and DBAL 4 support"},{"content":"API Platform 3.3 shipped in April 2024 with a set of targeted additions. None of them reshape the architecture — 3.2 already closed that chapter. What 3.3 adds is control over things that were previously either hardcoded or required a workaround: response headers, link visibility on sub-resources, and webhooks in the generated spec.\nDeclarative header configuration Before 3.3, setting custom response headers required either a custom processor that modified the response object or a Symfony event listener on kernel.response. Both approaches worked but lived outside the resource definition.\n3.3 adds a parameters parameter to operation metadata:\nuse ApiPlatform\\Metadata\\Get; use ApiPlatform\\Metadata\\HeaderParameter; #[Get( parameters: [ \u0026#39;X-Custom-Header\u0026#39; =\u0026gt; new HeaderParameter(description: \u0026#39;A custom header\u0026#39;), ] )] For headers that vary per response (like Cache-Control with a computed max-age), the processor can still set them directly on the response. The headers parameter is primarily for documenting expected headers in the OpenAPI spec and for static header values.\nLink security on sub-resources When a resource exposes links to related resources, those links appear in the serialized output regardless of whether the current user can access the linked resource. This creates a disclosure problem: a user who can read a book but not its author profile still sees the author\u0026rsquo;s URI in the response.\n3.3 adds security expressions to the Link descriptor:\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Get; use ApiPlatform\\Metadata\\Link; #[ApiResource] #[Get] class Book { #[Link( toClass: Author::class, security: \u0026#34;is_granted(\u0026#39;ROLE_ADMIN\u0026#39;)\u0026#34; )] public Author $author; } The link is omitted from the response when the security expression evaluates to false. The linked resource itself is not affected — only whether the current response includes the reference to it.\nApiProperty::security The same security expression mechanism is available at the property level via ApiProperty::security. This lets you hide individual fields based on the current user without writing a custom normalizer:\nuse ApiPlatform\\Metadata\\ApiProperty; class Book { #[ApiProperty(security: \u0026#34;is_granted(\u0026#39;ROLE_ADMIN\u0026#39;)\u0026#34;)] public string $internalNote; } The property is excluded from serialization when the expression is false. This is cleaner than a normalizer for the common case of role-gated fields.\nOpenAPI webhooks OpenAPI 3.1 supports webhooks — outbound HTTP calls that your API makes to registered listeners — in the spec document itself. Before 3.3, there was no way to document these in API Platform\u0026rsquo;s generated spec.\n3.3 adds a Webhook class you pass to the openapi parameter of an operation. Declare a dedicated PHP class with #[ApiResource] and use Webhook on each operation to describe the outbound call shape:\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Post; use ApiPlatform\\OpenApi\\Attributes\\Webhook; use ApiPlatform\\OpenApi\\Model\\Operation; use ApiPlatform\\OpenApi\\Model\\PathItem; #[ApiResource( operations: [ new Post( openapi: new Webhook( name: \u0026#39;bookCreated\u0026#39;, pathItem: new PathItem( post: new Operation(summary: \u0026#39;A book was created\u0026#39;), ), ) ), ] )] class BookWebhook {} The webhook definitions appear in the generated spec under the webhooks key alongside regular paths. Swagger UI renders them in a separate section.\nSwagger UI deep linking Swagger UI supports deep linking — bookmarkable URLs that open directly to a specific operation in the interface. Before 3.3, the API Platform integration did not enable this. 3.3 turns on the Swagger UI deepLinking option, configurable via swagger_ui_extra_configuration:\napi_platform: openapi: swagger_ui_extra_configuration: deepLinking: true With this enabled, the URL fragment updates as you navigate the UI, and pasting or sharing the URL opens the same operation. Useful when writing docs that link directly to a specific endpoint.\nStrict query parameter validation 3.3 tightens the query parameter validator: parameters not declared on the operation now return a 400 response instead of being silently ignored. This behavior is opt-in:\napi_platform: validator: query_parameter_validation: true The intent is to catch typos and API misuse early. If you rely on pass-through query parameters for custom logic (logging, feature flags), you need to declare them explicitly on the operation before enabling this.\n","permalink":"https://guillaumedelre.github.io/2024/04/29/api-platform-3.3-headers-link-security-and-openapi-webhooks/","summary":"\u003cp\u003eAPI Platform 3.3 shipped in April 2024 with a set of targeted additions. None of them reshape the architecture — 3.2 already closed that chapter. What 3.3 adds is control over things that were previously either hardcoded or required a workaround: response headers, link visibility on sub-resources, and webhooks in the generated spec.\u003c/p\u003e\n\u003ch2 id=\"declarative-header-configuration\"\u003eDeclarative header configuration\u003c/h2\u003e\n\u003cp\u003eBefore 3.3, setting custom response headers required either a custom processor that modified the response object or a Symfony event listener on \u003ccode\u003ekernel.response\u003c/code\u003e. Both approaches worked but lived outside the resource definition.\u003c/p\u003e","title":"API Platform 3.3: headers, link security, and OpenAPI webhooks"},{"content":"Symfony 7.0 landed November 29, 2023, same day as 6.4. The pattern holds: the X.0 release cuts deprecated code and raises the PHP floor. 7.0 requires PHP 8.2 and removes everything that 6.4 flagged as deprecated.\nThe most visible removal: Doctrine annotations. @Route, @ORM\\Column, @Assert - gone. Native PHP attributes have been the recommended approach since Symfony 5.2. 7.0 just makes it official.\nAttributes everywhere The migration from annotations to attributes is mostly mechanical: syntax changes from @ to #[], and the class references move from Doctrine annotation classes to PHP attribute classes:\n// before /** @Route(\u0026#39;/users\u0026#39;, methods={\u0026#34;GET\u0026#34;}) */ // after #[Route(\u0026#39;/users\u0026#39;, methods: [\u0026#39;GET\u0026#39;])] The real win isn\u0026rsquo;t just the syntax: attributes are validated by the PHP engine, not a docblock parser. IDEs can resolve them without custom plugins. Static analysis tools understand them natively. No more \u0026ldquo;it fails silently at runtime because of a typo in a comment.\u0026rdquo;\nWorkflow with PHP attributes Workflow event listeners and guards can now be registered via attributes:\n#[AsGuard(workflow: \u0026#39;order\u0026#39;, transition: \u0026#39;ship\u0026#39;)] public function canShip(Event $event): void { if (!$event-\u0026gt;getSubject()-\u0026gt;isPaymentConfirmed()) { $event-\u0026gt;setBlocked(true); } } The workflow profiler, a dedicated panel showing the current marking and available transitions, is a genuinely useful debugging tool if you\u0026rsquo;re working with complex state machines.\n:clock1: DatePoint in the Clock component DatePoint, the immutable DateTime with strict error handling introduced in 6.4, is now the recommended way to work with dates. Combine it with PHP 8.2\u0026rsquo;s readonly properties and date value objects in domain code become almost trivially clean:\nreadonly class Order { public function __construct( public DatePoint $createdAt, public ?DatePoint $shippedAt = null, ) {} } What 7.0 removes The full removal list: Doctrine annotations support, the Templating component bridge, ProxyManager bridge, the Monolog bridge for versions below 3.0, and the Sendinblue transport (replaced by Brevo). PHP 8.0 and 8.1 support also ends. 8.2 is the floor now.\nUpgrade from 6.4 with all deprecation notices fixed, and 7.0 is smooth. Skip that step and you\u0026rsquo;re in for a bad time.\nScheduler and AssetMapper graduate Two components that shipped as experimental in 6.3 are now stable: Scheduler and AssetMapper. Stable means locked APIs, no more @experimental caveats, and they show up properly in the upgrade guide. You can actually rely on them now.\nScheduler gets #[AsCronTask] and #[AsPeriodicTask] for attribute-based task registration, runtime schedule modification with heap recalculation, FailureEvent, and a --date option on schedule:debug. AssetMapper adds CSS file support in importmap, an outdated command, an audit command, and automatic preloading via WebLink.\n#[AsCronTask(\u0026#39;0 2 * * *\u0026#39;)] class NightlyReportMessage {} #[AsPeriodicTask(frequency: \u0026#39;1 hour\u0026#39;)] class HourlyCleanupMessage {} Service wiring gets two new attributes #[AutowireLocator] and #[AutowireIterator] landed in 6.4 and graduate to stable in 7.0. They replace the verbose XML/YAML tagged service locator config with something you can just put directly in PHP:\nclass HandlerRegistry { public function __construct( #[AutowireLocator(\u0026#39;app.handler\u0026#39;, indexAttribute: \u0026#39;key\u0026#39;)] private ContainerInterface $handlers, ) {} } #[Target] also gets smarter: when a service has a named autowiring alias like invoice.lock.factory, you can now write #[Target('invoice')] instead of the full alias name. Less noise when the type already tells you what you want.\nMessenger gets more precise failure handling RejectRedeliveredMessageException tells the worker to not retry a message, which is handy when a message arrives twice because of a transport ack timeout and you need exactly-once semantics. messenger:failed:remove --all clears the entire failure transport in one shot, no loop required. Failed retries can also go directly to the failure transport, bypassing the retry queue entirely.\nMultiple Redis Sentinel hosts are now supported in the DSN:\nredis-sentinel://host1:26379,host2:26379,host3:26379/mymaster Console gets signal names and command profiling SignalMap maps signal integers to their POSIX names. When a worker catches SIGTERM, the log now says SIGTERM instead of 15. Small thing, real improvement. ConsoleTerminateEvent is dispatched even when the process exits via signal, which wasn\u0026rsquo;t the case before 7.0.\nCommand profiling lands too: pass --profile to bin/console and the collected data goes straight into the Symfony profiler, browsable from the web UI.\nForm: small things that add up ChoiceType gets a duplicate_preferred_choices option. Set it to false and you stop showing the same option twice when preferred choices overlap with the full list. FormEvent::setData() is deprecated for events where the data is already locked at that point in the lifecycle. The self-closing slash on \u0026lt;input\u0026gt; elements is also gone: \u0026lt;input\u0026gt; is a void element in HTML5 and the slash was technically invalid.\nEnum support in forms is a nice one: ChoiceType renders backed enums directly, and translatable enums get their labels through the translator without any custom wiring.\nHttpFoundation: small but useful Response::send() gets a $flush parameter. Pass false to buffer the output without flushing to the client, useful when chaining middleware that needs to inspect the response before it leaves the process.\nUriSigner moves from HttpKernel to HttpFoundation, where it belongs semantically. Same class name, different namespace.\nCookies get CHIPS support (Cookies Having Independent Partitioned State), the browser mechanism for cross-site cookies in a first-party partition. Only matters if you build embeddable widgets, but good to know it\u0026rsquo;s there.\nTranslation: Phrase provider and tree output Phrase joins Crowdin and Lokalise as a supported translation provider. Configure it in config/packages/translation.yaml and the translation:push / translation:pull commands handle the sync.\ntranslation:pull gets an --as-tree option that writes translation files in nested YAML rather than flat dot-notation keys. Whether that\u0026rsquo;s actually better depends entirely on your team.\nLocaleSwitcher::runWithLocale() now passes the current locale as an argument to the callback, saving you a getLocale() call inside:\n$switcher-\u0026gt;runWithLocale(\u0026#39;fr\u0026#39;, function (string $locale) use ($mailer) { $mailer-\u0026gt;send($this-\u0026gt;buildEmail($locale)); }); A few things in Serializer and DomCrawler The Serializer\u0026rsquo;s Context attribute can now target specific classes, so a single DTO can behave differently during (de)serialization depending on which class holds the context. TranslatableNormalizer lands for normalizing objects that implement TranslatableInterface: the translator is called during normalization, not before.\nCrawler::attr() gains a $default parameter. Instead of null-checking the return value, pass a fallback:\n$src = $crawler-\u0026gt;attr(\u0026#39;src\u0026#39;, \u0026#39;/placeholder.png\u0026#39;); assertAnySelectorText() and assertAnySelectorTextContains() join the DomCrawler assertion set. They pass if at least one matching element satisfies the condition, rather than requiring all of them to match.\nHttpClient: HAR responses for testing MockResponse now accepts HAR (HTTP Archive) files. Record real HTTP interactions in your browser or with a proxy, drop the .har file in your test fixtures, and replay them:\n$client = new MockHttpClient(HarFileResponseFactory::createFromFile(__DIR__.\u0026#39;/fixtures/api.har\u0026#39;)); Much better than writing response stubs by hand when you\u0026rsquo;re dealing with a complex API.\n","permalink":"https://guillaumedelre.github.io/2024/01/12/symfony-7.0-php-8.2-minimum-and-annotations-finally-gone/","summary":"\u003cp\u003eSymfony 7.0 landed November 29, 2023, same day as 6.4. The pattern holds: the X.0 release cuts deprecated code and raises the PHP floor. 7.0 requires PHP 8.2 and removes everything that 6.4 flagged as deprecated.\u003c/p\u003e\n\u003cp\u003eThe most visible removal: Doctrine annotations. \u003ccode\u003e@Route\u003c/code\u003e, \u003ccode\u003e@ORM\\Column\u003c/code\u003e, \u003ccode\u003e@Assert\u003c/code\u003e - gone. Native PHP attributes have been the recommended approach since Symfony 5.2. 7.0 just makes it official.\u003c/p\u003e\n\u003ch2 id=\"attributes-everywhere\"\u003eAttributes everywhere\u003c/h2\u003e\n\u003cp\u003eThe migration from annotations to attributes is mostly mechanical: syntax changes from \u003ccode\u003e@\u003c/code\u003e to \u003ccode\u003e#[]\u003c/code\u003e, and the class references move from Doctrine annotation classes to PHP attribute classes:\u003c/p\u003e","title":"Symfony 7.0: PHP 8.2 minimum and annotations finally gone"},{"content":"Symfony 6.4 landed November 29, 2023. It\u0026rsquo;s an LTS with a story: four components that shipped as experimental in earlier releases are now stable. The biggest deal is AssetMapper.\nAssetMapper Modern frontend tooling in Symfony meant Webpack Encore. Encore works: it handles transpilation, bundling, versioning, hot reload. It also requires Node.js, a separate build step, and a non-trivial amount of configuration for what is often a pretty modest frontend.\nAssetMapper takes a different position. Modern browsers support ES modules natively. Instead of bundling, ship the files as-is, let the browser resolve imports through an importmap, and manage vendor dependencies through downloaded files rather than npm packages.\ncomposer require symfony/asset-mapper php bin/console importmap:require lodash No Node.js. No npm. No build step. JavaScript and CSS files are versioned and served directly, with a digest in the URL for cache busting. For apps where the frontend is not the primary engineering concern, this removes an entire toolchain from the equation.\n6.4 adds CSS files to the importmap, automatic CSS preloading via WebLink, and commands to audit and update vendor dependencies. The package.json experience, minus npm.\nScheduler The Scheduler component (periodic and cron-style task scheduling without an external job runner) exits experimental and becomes stable. The API uses attributes:\n#[AsCronTask(\u0026#39;0 * * * *\u0026#39;)] class HourlyReport implements ScheduledTaskInterface { public function run(): void { ... } } Backed by Messenger transports, tasks run in any environment where a worker is running. For many use cases, this replaces the classic cron entry + console command pattern.\nWebhook and RemoteEvent Also graduating from experimental: the Webhook component handles incoming webhooks from external services. Instead of writing raw controllers that parse payloads and dispatch events by hand, you configure parsers for known services (Stripe, GitHub, Mailgun) and get typed events.\n:clock3: DatePoint A new DatePoint class in the Clock component: an immutable DateTime wrapper that throws exceptions on invalid modifiers instead of silently returning false. Small thing, but meaningful for code that manipulates dates and actually wants to know when something goes wrong.\nThe support window 6.4 LTS gets bug fixes until November 2026 and security fixes until November 2027. The path from 6.4 to 7.4 (the next LTS) runs through the 6.4 deprecation notices, as usual.\nRoutes without magic strings FQCN-based route aliases are now generated automatically. If a controller method has a single route, Symfony creates an alias using its fully qualified class name:\n// Previously: only \u0026#39;blog_index\u0026#39; worked // Now: both work identically $this-\u0026gt;urlGenerator-\u0026gt;generate(\u0026#39;blog_index\u0026#39;); $this-\u0026gt;urlGenerator-\u0026gt;generate(BlogController::class.\u0026#39;::index\u0026#39;); For invokable controllers, the alias is just the class name. The practical benefit is IDE navigation and refactoring safety: you\u0026rsquo;re referencing a class constant, not a string that can silently drift.\nTwo new DI attributes #[AutowireLocator] and #[AutowireIterator] join the DI attribute family. Instead of configuring service locators and tagged iterables in YAML, you just declare them on constructor parameters:\npublic function __construct( #[AutowireLocator([FooHandler::class, BarHandler::class])] private ContainerInterface $handlers, ) {} Aliases, optional services (prefixed with ?), and parameter injection via SubscribedService are all supported. The locator lazy-loads, so only the handlers you actually call get instantiated.\nMessenger gets built-in handlers Three new message classes cover common tasks that previously required custom handlers.\nRunProcessMessage dispatches a Process command through the bus. RunCommandMessage does the same for console commands. Both return a context object with the exit code and output. PingWebhookMessage pings a URL, which is useful for monitoring scheduled tasks without spinning up a dedicated health-check service:\n$this-\u0026gt;bus-\u0026gt;dispatch(new RunCommandMessage(\u0026#39;cache:clear\u0026#39;)); $this-\u0026gt;bus-\u0026gt;dispatch(new PingWebhookMessage(\u0026#39;GET\u0026#39;, \u0026#39;https://healthchecks.io/ping/abc123\u0026#39;)); The subprocess inheritance problem also got addressed with PhpSubprocess. When you run PHP with a custom memory limit (-d memory_limit=-1), child processes launched with Process don\u0026rsquo;t inherit it. PhpSubprocess does:\n$sub = new PhpSubprocess([\u0026#39;bin/console\u0026#39;, \u0026#39;app:heavy-import\u0026#39;]); Security: three fixes for real situations The profiler now shows how security badges were resolved during authentication: which ones passed, which failed, and why. Before, you had to add debug output manually when a custom authenticator wasn\u0026rsquo;t behaving.\nLogin throttling via RateLimiter now hashes PII in logs automatically. IP addresses and usernames get hashed with the kernel secret before they\u0026rsquo;re written. No config needed, no regex on log lines.\nFirewall patterns now accept arrays:\nfirewalls: no_security: pattern: - \u0026#34;^/register$\u0026#34; - \u0026#34;^/api/webhooks/\u0026#34; No more regex gymnastics for multi-path exclusions.\nLogout without a dummy controller The logout route used to require a controller that did nothing but throw an exception, with a comment explaining that yes, this is intentional. 6.4 eliminates that:\n# config/routes/security.yaml _security_logout: resource: security.route_loader.logout type: service The route loader handles it. The dummy controller is gone. Flex updates the recipe.\nThe serializer in better shape Three serializer improvements that each solve a real problem.\nClass-level #[Groups] attribute: apply a group to the entire class, then override per property. Useful when a resource has a default serialization group and a few fields that need finer control.\nTranslatable objects now have a dedicated normalizer. Translatable strings (wrapping Doctrine\u0026rsquo;s TranslatableInterface) get translated to the locale passed via NORMALIZATION_LOCALE_KEY during normalization. Before this, you had to write a custom normalizer.\nIn debug mode, JSON decoding errors now use seld/jsonlint for better messages. Instead of \u0026ldquo;Syntax error\u0026rdquo;, you get the line and what actually went wrong:\nParse error on line 1: {\u0026#39;foo\u0026#39;: \u0026#39;bar\u0026#39;} ^ Invalid string, used single quotes instead of double quotes Profilers for the things that weren\u0026rsquo;t HTTP requests The command profiler extends the existing profiler to console commands. Add --profile to any command and get a full profiler entry: input/output, execution time, memory, database queries, log messages. Commands that used to need --verbose plus manual timing now have the same debugging experience as HTTP requests.\nThe workflow profiler does the same for state machines. A new panel shows a graphical representation of your workflows and which transitions fired during the request. Zero configuration.\nThe DX accumulation Several smaller additions that compound.\nrenderBlock() and renderBlockView() on AbstractController let you render a named Twig block and return it as a Response or string. Handy for Turbo Stream responses where you want to update a fragment without a full controller action.\nThe defined env var processor returns a boolean rather than the value: true if the variable exists and is non-empty, false otherwise. Useful for feature flags driven by environment variables:\nparameters: is_feature_enabled: \u0026#39;%env(defined:FEATURE_FLAG_KEY)%\u0026#39; HttpClient now accepts max_retries per request, overriding the global retry strategy. The Finder component\u0026rsquo;s filter() method accepts a second argument to prune entire directories early, which matters when you\u0026rsquo;re searching large trees.\nThe BrowserKit click() method now accepts server parameters as extra headers, useful in functional tests that need to simulate authenticated API calls while following links.\nImpersonation becomes usable in templates Two new Twig helpers: impersonation_path() and impersonation_url(). They generate the correct URLs including the switch-user query parameter, which is configurable and has no business being hardcoded in templates. Pair them with the existing impersonation_exit_path() for the full admin impersonation flow.\nLocale control, everywhere it was missing Three gaps filled. TemplatedEmail now has a locale() method for rendering emails in the recipient\u0026rsquo;s language. The locale switcher\u0026rsquo;s runWithLocale() now passes the locale as an argument to the callback, so you don\u0026rsquo;t have to capture it from the outer scope. And app.enabledLocales is available in Twig, so you can build language switchers without hardcoding locale lists.\nDeploying to read-only filesystems APP_BUILD_DIR is now an environment variable recognized by the kernel. Set it to redirect compiled artifacts (router cache, Doctrine proxies, preloaded translations) to a directory that exists, even when the default cache directory doesn\u0026rsquo;t. The MicroKernelTrait uses it automatically. The WarmableInterface gained a $buildDir parameter to support this separation: custom cache warmers that write read-only artifacts should update accordingly.\n","permalink":"https://guillaumedelre.github.io/2024/01/10/symfony-6.4-lts-assetmapper-scheduler-webhook-and-the-long-term-release/","summary":"\u003cp\u003eSymfony 6.4 landed November 29, 2023. It\u0026rsquo;s an LTS with a story: four components that shipped as experimental in earlier releases are now stable. The biggest deal is AssetMapper.\u003c/p\u003e\n\u003ch2 id=\"assetmapper\"\u003eAssetMapper\u003c/h2\u003e\n\u003cp\u003eModern frontend tooling in Symfony meant Webpack Encore. Encore works: it handles transpilation, bundling, versioning, hot reload. It also requires Node.js, a separate build step, and a non-trivial amount of configuration for what is often a pretty modest frontend.\u003c/p\u003e\n\u003cp\u003eAssetMapper takes a different position. Modern browsers support ES modules natively. Instead of bundling, ship the files as-is, let the browser resolve imports through an importmap, and manage vendor dependencies through downloaded files rather than npm packages.\u003c/p\u003e","title":"Symfony 6.4 LTS: AssetMapper, Scheduler, Webhook, and the long-term release"},{"content":"PHP 8.3 landed November 23rd. Quiet release by PHP standards: no enum-sized shift, no JIT. What it does have is a focused set of improvements that close long-standing gaps in the type system and add functions that should have existed years ago.\nTyped class constants Class constants have been untyped since their introduction. PHP 8.3 fixes that:\ninterface HasVersion { const string VERSION; } class App implements HasVersion { const string VERSION = \u0026#39;1.0.0\u0026#39;; } Without typed constants, an interface constant could be overridden with a completely different type in an implementing class and nothing would complain. Typed constants close that gap, and on interface-driven codebases the impact is immediate.\nDynamic class constant access A gap that required a workaround since constants were introduced:\n$name = \u0026#39;STATUS\u0026#39;; echo MyClass::{$name}; // now works Before, accessing a constant with a dynamic name meant calling constant('MyClass::STATUS'). The new syntax is consistent with how PHP already handles variable variables and dynamic method calls.\nreadonly can now be amended in clone A specific but genuinely annoying limitation of 8.1 readonly: you couldn\u0026rsquo;t clone an object and change a readonly property. 8.3 adds the ability to reinitialize readonly properties during cloning, which makes immutable value objects usable in a lot more patterns.\njson_validate() if (json_validate($string)) { $data = json_decode($string); } Before 8.3, the only way to validate a JSON string was to decode it and check for errors. json_validate() checks without allocating the decoded structure, which matters when you only need to know if the string is valid JSON, not what\u0026rsquo;s in it.\nRandomizer improvements getBytesFromString() generates a random string composed only of characters from a given set:\n$rng = new Random\\Randomizer(); $token = $rng-\u0026gt;getBytesFromString(\u0026#39;abcdefghijklmnopqrstuvwxyz0123456789\u0026#39;, 32); The previous approach: str_split, array_map, random selection, implode. It worked, but it was longer than it had any right to be.\n8.3 is for the teams that adopt PHP versions quickly and want the incremental improvements. The typed constants alone are worth it on any codebase with interface constants.\n#[\\Override] makes inheritance explicit Before 8.3, nothing stopped you from writing a method you thought was overriding a parent\u0026rsquo;s, when you had a typo in the name or the parent had quietly removed it. Silent bugs, zero feedback from the engine.\nclass Cache { #[\\Override] public function get(string $key): mixed { // Engine verifies this method exists in a parent or interface } } If the method doesn\u0026rsquo;t exist in any parent class or implemented interface, PHP throws an error. Same concept as Java\u0026rsquo;s @Override or C#\u0026rsquo;s override, finally in PHP.\nfinal on trait methods Traits have always had rough edges in PHP\u0026rsquo;s OOP model. One specific problem: a class using a trait could override any of its methods, undermining whatever guarantees the trait was trying to provide. 8.3 lets the trait itself mark a method as final:\ntrait Singleton { final public static function getInstance(): static { // ... } } Now a class using the trait cannot override getInstance(). The guarantee holds.\nAnonymous classes can be readonly PHP 8.1 brought readonly classes. Anonymous classes were left out for some reason. 8.3 fixes that:\n$point = new readonly class(3, 4) { public function __construct( public float $x, public float $y, ) {} }; Handy when you need a throwaway immutable value object without the ceremony of naming it.\nStatic variable initializers accept expressions A small but long-standing restriction: static variable initializers only accepted constant expressions, no function calls. 8.3 drops that constraint:\nfunction connection(): PDO { static $pdo = new PDO(getenv(\u0026#39;DATABASE_URL\u0026#39;)); return $pdo; } The initializer runs once on first call, the static variable persists. Achievable with a null-check before, this is just cleaner.\nmb_str_pad() finally exists str_pad() has always been byte-aware, not character-aware. For multibyte strings (Arabic, Japanese, accented characters) it produced wrong output. 8.3 finally adds the multibyte variant:\n$padded = mb_str_pad(\u0026#39;日本\u0026#39;, 10, \u0026#39;*\u0026#39;, STR_PAD_BOTH); The function respects character boundaries, not byte counts.\nstr_increment() and str_decrement() PHP\u0026rsquo;s ++ operator on strings has a history of quirks: it increments letter sequences ('a' → 'b', 'z' → 'aa'), but -- never worked symmetrically. The behavior was surprising enough that 8.3 deprecates ++/-- on non-alphanumeric strings and introduces explicit functions:\necho str_increment(\u0026#39;a\u0026#39;); // b echo str_increment(\u0026#39;Az\u0026#39;); // Ba echo str_decrement(\u0026#39;b\u0026#39;); // a echo str_decrement(\u0026#39;Ba\u0026#39;); // Az The functions make the intent obvious and the behavior predictable.\nRandom\\Randomizer gets float support 8.3 fills in the float side of the Randomizer API:\n$rng = new Random\\Randomizer(); // A float in [0.0, 1.0) $f = $rng-\u0026gt;nextFloat(); // A float in a specific range with controlled boundary inclusion $f = $rng-\u0026gt;getFloat(1.5, 3.5, Random\\IntervalBoundary::ClosedOpen); IntervalBoundary is a new enum with four values: ClosedOpen, ClosedClosed, OpenClosed, OpenOpen. This matters for statistical correctness: the naive approach of rand() / getrandmax() doesn\u0026rsquo;t produce a uniform distribution over floats.\nThe Date exception hierarchy Date/time errors in PHP used to throw generic exceptions with no way to tell \u0026ldquo;malformed string\u0026rdquo; from \u0026ldquo;invalid timezone\u0026rdquo; without parsing the message yourself. 8.3 adds a proper hierarchy:\ntry { new DateTimeImmutable(\u0026#39;not a date\u0026#39;); } catch (DateMalformedStringException $e) { // specifically a parsing failure } catch (DateException $e) { // other date-related errors } The full tree: DateError (engine-level), DateException (base), with specific subclasses for invalid timezone, malformed interval string, malformed period string, and malformed date string.\ngc_status() tells you more gc_status() now returns eight additional fields: running, protected, full, buffer_size, and timing breakdowns (application_time, collector_time, destructor_time, free_time). If you\u0026rsquo;re profiling memory pressure or GC pauses, this data was previously unavailable without pulling in an extension.\nstrrchr() grows a direction argument strrchr() (find the last occurrence of a character, return from there to end) now accepts a $before_needle boolean, matching the API of strstr():\n$path = \u0026#39;/var/www/html/index.php\u0026#39;; echo strrchr($path, \u0026#39;/\u0026#39;, before_needle: true); // /var/www/html echo strrchr($path, \u0026#39;/\u0026#39;); // /index.php A function that\u0026rsquo;s been in PHP since 1994, finally consistent with its sibling.\nDeprecations worth noting get_class() and get_parent_class() without arguments now emit deprecation notices. The argumentless forms relied on implicit $this context, which was easy to misread. Pass the object explicitly.\nassert_options() and the ASSERT_* constants are deprecated in favor of the zend.assertions INI directive, which is the right tool for controlling assertion behavior across environments.\nThe ++/-- operators on empty strings and non-numeric non-alphanumeric strings now emit deprecation warnings. The behavior was undefined territory. 8.3 starts the migration toward defined behavior in 9.0.\nStack overflow protection Two new INI directives: zend.max_allowed_stack_size sets a hard limit on PHP\u0026rsquo;s stack depth, and zend.reserved_stack_size sets aside a buffer for cleanup after a limit is hit. Before 8.3, deeply recursive code could just crash at the OS level. Now PHP catches it and throws an Error with a useful message.\n","permalink":"https://guillaumedelre.github.io/2024/01/07/php-8.3-typed-constants-and-the-small-wins-that-stick/","summary":"\u003cp\u003ePHP 8.3 landed November 23rd. Quiet release by PHP standards: no enum-sized shift, no JIT. What it does have is a focused set of improvements that close long-standing gaps in the type system and add functions that should have existed years ago.\u003c/p\u003e\n\u003ch2 id=\"typed-class-constants\"\u003eTyped class constants\u003c/h2\u003e\n\u003cp\u003eClass constants have been untyped since their introduction. PHP 8.3 fixes that:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eHasVersion\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eVERSION\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eApp\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eimplements\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eHasVersion\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eVERSION\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;1.0.0\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eWithout typed constants, an interface constant could be overridden with a completely different type in an implementing class and nothing would complain. Typed constants close that gap, and on interface-driven codebases the impact is immediate.\u003c/p\u003e","title":"PHP 8.3: typed constants and the small wins that stick"},{"content":"API Platform 3.2 arrived in October 2023 with three changes that pushed the state model further: errors became resources, sub-resources came back in a form that actually fits the architecture, and the last legacy extension point — event listeners — was formally replaced.\nErrors as resources Before 3.2, error handling was outside the resource model. Exceptions were caught by a Symfony event listener and converted to a response, with limited control over the shape of the output.\n3.2 makes errors first-class ApiResource classes compliant with RFC 9457 (Problem Details for HTTP APIs). The built-in error class is ApiPlatform\\ApiResource\\Error, and you can create your own:\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\ErrorResource; use ApiPlatform\\Metadata\\Exception\\ProblemExceptionInterface; #[ApiResource] #[ErrorResource] class BookNotFoundError extends \\RuntimeException implements ProblemExceptionInterface { public function __construct(private readonly string $bookId) { parent::__construct(\u0026#34;Book $bookId not found\u0026#34;); } public function getType(): string { return \u0026#39;/errors/book-not-found\u0026#39;; } } When this exception is thrown anywhere in the state layer, API Platform catches it, serializes it as a Problem Detail response, and generates a proper OpenAPI schema for it. The error type, title, detail, and status are all part of the resource contract — not hardcoded strings in a listener.\nSub-resources without the workarounds Sub-resources existed in 2.x but were removed in 3.0 because they were tightly coupled to the old data provider model and couldn\u0026rsquo;t be cleanly mapped to the new operation-first architecture. 3.2 reintroduces them in a way that fits.\nA sub-resource is a resource accessible through a parent resource\u0026rsquo;s URI. In 3.2, it is declared directly on the child resource using uriTemplate:\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\GetCollection; #[ApiResource( operations: [ new GetCollection( uriTemplate: \u0026#39;/books/{bookId}/reviews\u0026#39;, uriVariables: [ \u0026#39;bookId\u0026#39; =\u0026gt; new Link(fromClass: Book::class), ], ), ] )] class Review { // ... } The Link descriptor makes the relationship explicit. The provider receives bookId in $uriVariables and can use it to scope the query. No magic inference, no implicit joins — the URI structure and the data access are both declared.\ncanonical_uri_template for multiple access paths When a resource is accessible through multiple URIs (a direct endpoint and a sub-resource endpoint), OpenAPI needs to know which URI is canonical for $ref links. 3.2 uses the top-level uriTemplate on ApiResource as the default canonical URI. For finer control, the canonical_uri_template option can be passed via extraProperties on any operation to override it explicitly.\n#[ApiResource( uriTemplate: \u0026#39;/reviews/{id}\u0026#39;, operations: [ new Get(), new GetCollection( uriTemplate: \u0026#39;/books/{bookId}/reviews\u0026#39;, uriVariables: [\u0026#39;bookId\u0026#39; =\u0026gt; new Link(fromClass: Book::class)], ), ] )] class Review {} The generated OpenAPI spec uses the canonical URI for schema references, keeping the document consistent when a resource appears under several paths.\nUnion and intersection types 3.2 adds support for PHP union and intersection types in the metadata layer. A property declared as Book|Magazine generates a proper oneOf schema in OpenAPI. This was previously unsupported — you had to fall back to an untyped mixed or annotate the property manually.\nEvent listeners made optional The last compatibility shim from 2.x was the ability to use Symfony event listeners on the kernel.request and kernel.view events to intercept API Platform\u0026rsquo;s data flow. 3.2 does not remove them, but introduces an opt-out: setting event_listeners_backward_compatibility_layer: false in the API Platform configuration disables the event-based hooks entirely. The replacement is a provider or processor decorated with another provider or processor. The event-based hook was stateful, order-dependent, and bypassed the operation context entirely. Decorated providers get the operation object and can call the inner provider when ready.\nThe state model is now complete 3.0 introduced the architecture. 3.1 added resource/entity separation. 3.2 closes the remaining gaps: errors have a resource contract, sub-resources have a clean declaration model, and the state layer now covers every extension point that event listeners once handled. The 2.x shims still exist, but opting out of them is now a single config flag.\n","permalink":"https://guillaumedelre.github.io/2023/10/12/api-platform-3.2-errors-as-resources-and-sub-resources-come-back/","summary":"\u003cp\u003eAPI Platform 3.2 arrived in October 2023 with three changes that pushed the state model further: errors became resources, sub-resources came back in a form that actually fits the architecture, and the last legacy extension point — event listeners — was formally replaced.\u003c/p\u003e\n\u003ch2 id=\"errors-as-resources\"\u003eErrors as resources\u003c/h2\u003e\n\u003cp\u003eBefore 3.2, error handling was outside the resource model. Exceptions were caught by a Symfony event listener and converted to a response, with limited control over the shape of the output.\u003c/p\u003e","title":"API Platform 3.2: errors as resources and sub-resources come back"},{"content":"Four months after 3.0, API Platform 3.1 arrived with the first batch of features built on the new state model. Not every change is dramatic, but one of them solves a problem that drove a lot of convoluted workarounds in 2.x: your API resource no longer needs to be your Doctrine entity.\nThe resource/entity split In 2.x, API Platform worked best when your API resource and your persistence model were the same class. Using a DTO as the API surface was possible through the Input/Output DTO system, but that system was removed in 3.0 — it complicated the state model without enough benefit.\n3.1 replaces it with something cleaner. The stateOptions parameter on an operation accepts a DoctrineOrmOptions object that points to a different entity:\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\Doctrine\\Orm\\State\\Options; #[ApiResource( operations: [ new GetCollection( stateOptions: new Options(entityClass: BookEntity::class), ), ] )] class BookDto { public string $title; public string $author; } The provider receives the BookEntity objects from Doctrine and the serialization layer maps them to BookDto. The Doctrine filters, pagination, and ordering all work on BookEntity. The API surface exposes BookDto. The two can evolve independently.\nThis matters more than it looks. Your persistence model accumulates internal fields, relations, and columns that have no business being in your API. Before 3.1, you either exposed them anyway or built an elaborate normalizer to hide them. Now you declare what the API looks like as a separate class and let the framework handle the mapping.\nPUT that follows the spec Since version 1.0, API Platform\u0026rsquo;s PUT handler updated existing resources. Creating a resource via PUT — which the HTTP spec explicitly allows — was not supported. 3.1 adds uriTemplate-based creation:\n#[Put( uriTemplate: \u0026#39;/books/{id}\u0026#39;, allowCreate: true, )] With allowCreate: true, a PUT to a URI that does not exist creates the resource instead of returning 404. The identifier comes from the URI, not from the request body. This is what RFC 9110 describes for PUT: \u0026ldquo;If the target resource does not have a current representation and the PUT successfully creates one, then the origin server MUST inform the user agent by sending a 201 (Created) response.\u0026rdquo;\nIt is a small flag, but it opens API Platform to use cases — idempotent resource creation, client-assigned identifiers — that previously required a custom controller.\nDenormalization errors collected, not thrown Before 3.1, deserialization errors stopped at the first problem. Send a request body with five invalid fields and get an error about the first one. Fix it, send again, find the second. Repeat five times.\n3.1 adds a collect_denormalization_errors option on the operation that changes this behavior:\n#[Post(collectDenormalizationErrors: true)] With this enabled, API Platform catches all type errors and constraint violations during deserialization and returns them as a structured list in the response, formatted the same way as validation errors. One round-trip, full picture.\nApiResource::openapi replaces openapiContext The old openapiContext parameter accepted a raw array that was merged into the generated OpenAPI schema — convenient but untyped. 3.1 introduces a first-class openapi parameter that accepts an OpenApiOperation object:\nuse ApiPlatform\\OpenApi\\Model\\Operation; use ApiPlatform\\OpenApi\\Model\\RequestBody; #[Post( openapi: new Operation( requestBody: new RequestBody( description: \u0026#39;Create a book\u0026#39;, required: true, ), summary: \u0026#39;Create a new book entry\u0026#39;, ) )] The old openapiContext array still works but is deprecated. The new approach is typed, IDE-friendly, and validates at construction time rather than at schema generation time. PHP 8.1 backed enums also get proper OpenAPI schema generation in 3.1 — a field typed as a backed enum produces a schema with enum values and the correct type, without any annotation.\nThe pattern is clear 3.0 established the architecture. 3.1 shows what that architecture enables: clean resource/entity separation without a parallel DTO system, RFC-correct HTTP semantics, better error reporting. None of these would have been as clean to implement on the 2.x data provider model. The features in 3.1 are the first proof that the rewrite was the right call.\n","permalink":"https://guillaumedelre.github.io/2023/01/23/api-platform-3.1-your-resource-doesnt-have-to-be-your-entity/","summary":"\u003cp\u003eFour months after 3.0, API Platform 3.1 arrived with the first batch of features built on the new state model. Not every change is dramatic, but one of them solves a problem that drove a lot of convoluted workarounds in 2.x: your API resource no longer needs to be your Doctrine entity.\u003c/p\u003e\n\u003ch2 id=\"the-resourceentity-split\"\u003eThe resource/entity split\u003c/h2\u003e\n\u003cp\u003eIn 2.x, API Platform worked best when your API resource and your persistence model were the same class. Using a DTO as the API surface was possible through the Input/Output DTO system, but that system was removed in 3.0 — it complicated the state model without enough benefit.\u003c/p\u003e","title":"API Platform 3.1: your resource doesn't have to be your entity"},{"content":"PHP 8.2 dropped December 8th. Readonly classes are the headline. The deprecation of dynamic properties is the one that actually requires your attention.\nDynamic properties deprecated PHP has always allowed adding properties to objects that weren\u0026rsquo;t declared in the class:\nclass User {} $user = new User(); $user-\u0026gt;name = \u0026#39;Alice\u0026#39;; // no declaration, no error... until now In 8.2, this triggers a deprecation notice. In PHP 9.0 it becomes a fatal error. The grace period exists, but the migration clock is running.\nThe reasoning is solid: dynamic properties are a classic source of typos that silently pass (write $user-\u0026gt;nmae and PHP just creates a new property instead of complaining). Explicit declarations make the class contract clear and make tooling actually useful.\nMigration is mostly mechanical: declare the properties, or slap #[AllowDynamicProperties] on legacy classes you can\u0026rsquo;t touch yet.\nReadonly classes 8.1 added readonly for individual properties. 8.2 adds it to the class declaration itself:\nreadonly class Point { public function __construct( public float $x, public float $y, public float $z, ) {} } All promoted and explicitly declared properties become readonly automatically. Value objects (coordinates, money amounts, identifiers) are the obvious target. The syntax is clean and the intent reads clearly.\nOne constraint: readonly classes can\u0026rsquo;t have non-typed properties, which were already a bad idea with readonly anyway.\nDNF types Disjunctive Normal Form types let you combine union and intersection types:\nfunction process(Countable\u0026amp;Iterator|null $collection): void { ... } (Countable\u0026amp;Iterator)|null: an object that implements both interfaces, or null. This covers type expressions that 8.0 union types and 8.1 intersection types each got partway to but couldn\u0026rsquo;t represent together.\nThe Random extension A dedicated Random extension replaces the scattered rand(), mt_rand(), random_int() functions with an object-oriented API:\n$rng = new Random\\Randomizer(); $rng-\u0026gt;getInt(1, 100); $rng-\u0026gt;shuffleArray($items); Engines are swappable: Mersenne Twister, PCG64, Xoshiro256StarStar, or CryptoSafeEngine for security-sensitive contexts. Same code, seeded deterministic engine in tests, cryptographic engine in production.\n8.2 is a consolidation release. The dynamic properties deprecation is the one decision you need to make now.\nnull, false, and true as standalone types PHP has had nullable types since 7.1 and union types since 8.0, but null as a standalone type declaration wasn\u0026rsquo;t valid. 8.2 fixes that:\nfunction alwaysNull(): null { return null; } function disabled(): false { return false; } function enabled(): true { return true; } false and true as standalone types are useful when you need to be precise about what a function can actually return. It\u0026rsquo;s narrow but correct: a function that returns false on failure and a string on success should declare string|false, and now both sides of that union are real types.\nConstants in traits Traits could hold properties and methods. Constants were the odd gap. 8.2 closes it:\ntrait Timestamps { public const DATE_FORMAT = \u0026#39;Y-m-d H:i:s\u0026#39;; public function formatCreatedAt(): string { return $this-\u0026gt;createdAt-\u0026gt;format(self::DATE_FORMAT); } } class Article { use Timestamps; } echo Article::DATE_FORMAT; // \u0026#39;Y-m-d H:i:s\u0026#39; The constant belongs to the class that uses the trait, not the trait itself, so you can\u0026rsquo;t access Timestamps::DATE_FORMAT directly. Expected scoping behavior, consistent with how trait methods already work.\n#[SensitiveParameter] Stack traces have always been a liability: function arguments get logged verbatim, which means passwords and tokens end up in your error logs and monitoring dashboards. 8.2 adds an attribute to stop that:\nfunction authenticate( string $user, #[\\SensitiveParameter] string $password, ): bool { // if this throws, the stack trace shows: // authenticate(\u0026#39;alice\u0026#39;, Object(SensitiveParameterValue)) return hash(\u0026#39;sha256\u0026#39;, $password) === getStoredHash($user); } The parameter value in the trace gets replaced with a SensitiveParameterValue object. One attribute, zero excuses not to add it to every function that touches credentials.\nDeprecated string interpolation syntaxes Two ways to interpolate expressions inside strings are deprecated in 8.2:\n$name = \u0026#39;world\u0026#39;; // These are deprecated: echo \u0026#34;Hello ${name}\u0026#34;; // use \u0026#34;$name\u0026#34; or \u0026#34;{$name}\u0026#34; echo \u0026#34;Hello ${getName()}\u0026#34;; // use \u0026#34;{$this-\u0026gt;getName()}\u0026#34; The ${...} forms created ambiguity between variable variables and expressions. The cleaner {$...} syntax has always been there and does the same thing. This is mostly a search-and-replace job in codebases that picked up the deprecated forms out of habit.\nutf8_encode() and utf8_decode() deprecated These two functions are deprecated in 8.2 and gone in 9.0. Their behavior was always narrower than the names suggested: utf8_encode() converts ISO-8859-1 to UTF-8, not \u0026ldquo;any encoding to UTF-8.\u0026rdquo;\n// Deprecated in 8.2: $utf8 = utf8_encode($latin1String); // Use instead: $utf8 = mb_convert_encoding($latin1String, \u0026#39;UTF-8\u0026#39;, \u0026#39;ISO-8859-1\u0026#39;); mb_convert_encoding() or iconv() handle the general case. If you\u0026rsquo;re actually dealing with Latin-1 input, the replacement is a direct swap.\nLocale-independent string functions Several string functions silently varied behavior based on the system locale, producing different results in production versus a dev container. In 8.2, they\u0026rsquo;re locale-independent and ASCII-only:\n// strtolower, strtoupper, stristr, stripos, strripos, // lcfirst, ucfirst, ucwords, str_ireplace now do ASCII case conversion only. // For locale-aware behavior, use mb_* equivalents: $lowered = mb_strtolower($text, \u0026#39;UTF-8\u0026#39;); This is a correctness fix. If your code was relying on locale-sensitive behavior from these functions, it was already broken on systems with different locale configurations. 8.2 makes the behavior deterministic everywhere, which is what you actually wanted.\nstr_split() on empty string A quiet behavior change worth noting:\n// PHP 8.1: str_split(\u0026#39;\u0026#39;) === [\u0026#39;\u0026#39;] // PHP 8.2: str_split(\u0026#39;\u0026#39;) === [] The new behavior makes more sense: splitting nothing produces nothing. If you\u0026rsquo;re checking count(str_split($input)), an empty input no longer produces a count of 1.\n","permalink":"https://guillaumedelre.github.io/2023/01/22/php-8.2-readonly-classes-and-the-deprecation-that-matters/","summary":"\u003cp\u003ePHP 8.2 dropped December 8th. Readonly classes are the headline. The deprecation of dynamic properties is the one that actually requires your attention.\u003c/p\u003e\n\u003ch2 id=\"dynamic-properties-deprecated\"\u003eDynamic properties deprecated\u003c/h2\u003e\n\u003cp\u003ePHP has always allowed adding properties to objects that weren\u0026rsquo;t declared in the class:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e {}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$user \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$user\u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Alice\u0026#39;\u003c/span\u003e; \u003cspan style=\"color:#75715e\"\u003e// no declaration, no error... until now\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eIn 8.2, this triggers a deprecation notice. In PHP 9.0 it becomes a fatal error. The grace period exists, but the migration clock is running.\u003c/p\u003e","title":"PHP 8.2: readonly classes and the deprecation that matters"},{"content":"API Platform 3.0 arrived in September 2022 with Symfony 6.1 as a hard minimum and a core architecture that looked nothing like 2.x. The migration guide is long. The reason it\u0026rsquo;s long is interesting.\nThe old model had a conceptual leak. DataProviderInterface and DataPersisterInterface were called for every HTTP request, but the provider received the operation context as a hint — not as a contract. A collection provider and an item provider were separate interfaces, but both lived in the same mental bucket: \u0026ldquo;things that return data.\u0026rdquo; The HTTP layer knew what was being requested; the provider had to reconstruct that knowledge from context clues passed in the $context array.\n3.0 inverts the model. Operations are declared first. Data access is wired to operations.\nState providers replaced data providers The old DataProviderInterface is gone. The replacement is ProviderInterface:\nuse ApiPlatform\\State\\ProviderInterface; use ApiPlatform\\Metadata\\Operation; class BookProvider implements ProviderInterface { public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { if ($operation instanceof CollectionOperationInterface) { return $this-\u0026gt;repository-\u0026gt;findAll(); } return $this-\u0026gt;repository-\u0026gt;find($uriVariables[\u0026#39;id\u0026#39;]); } } The difference is not syntactic. In 2.x, you registered a provider and API Platform called it for any matching resource. In 3.0, you bind a provider to a specific operation. The provider no longer guesses what triggered it — the operation object it receives is the contract.\nState processors replaced data persisters DataPersisterInterface had the same problem on the write side: one class handling create, update, and delete, distinguishing them by inspecting the HTTP method or the object state. ProcessorInterface receives the operation as a typed argument:\nuse ApiPlatform\\State\\ProcessorInterface; use ApiPlatform\\Metadata\\Operation; class BookProcessor implements ProcessorInterface { public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { $this-\u0026gt;entityManager-\u0026gt;persist($data); $this-\u0026gt;entityManager-\u0026gt;flush(); return $data; } } More usefully: you can bind a different processor per operation. The delete operation gets one that removes. The post operation gets one that validates and stores. No switch statement, no method inspection, no shared class trying to be three things at once.\nOperations declared explicitly in PHP 8.1 attributes The other half of 3.0 is the metadata layer. Doctrine annotations are replaced by PHP 8.1 native attributes, and each operation is declared explicitly on the resource class:\nuse ApiPlatform\\Metadata\\ApiResource; use ApiPlatform\\Metadata\\Get; use ApiPlatform\\Metadata\\GetCollection; use ApiPlatform\\Metadata\\Post; #[ApiResource( operations: [ new GetCollection(provider: BookProvider::class), new Get(provider: BookProvider::class), new Post(processor: BookProcessor::class), ] )] class Book { // ... } This is more verbose than @ApiResource with magic defaults. It is also explicit. You know exactly what HTTP operations exist for this resource, what retrieves data, what writes it, and where the logic lives. The defaults of 2.x were convenient until the day you needed to override one and couldn\u0026rsquo;t figure out which service to decorate without reading the source.\nPHP 8.1 was not a coincidence The hard requirement for PHP 8.1 is load-bearing. First-class callables make filter registration cleaner. The immutability of operation metadata is enforced through cloning patterns (withX() methods) that rely on named arguments and promoted constructor properties — PHP 8.0 foundations the architecture builds on heavily.\nMore practically: the full expression of 3.0\u0026rsquo;s architecture — typed operations, operation-scoped providers, explicit metadata — needed 8.1 to not feel like workarounds. Dropping PHP 7.x and 8.0 was not a housekeeping decision.\nThe migration is real work The jump from 2.x to 3.0 is not a version bump. Every DataProvider becomes a ProviderInterface. Every DataPersister becomes a ProcessorInterface. Annotations become attributes. Custom normalizers and filters may need restructuring. The upgrade guide documents all of it, but \u0026ldquo;documented\u0026rdquo; does not mean \u0026ldquo;fast.\u0026rdquo;\nWhat you get on the other side is an architecture that scales without the ambient complexity of 2.x: no more guessing which interface to implement, no more $this-\u0026gt;supports() chains, no more invisible defaults quietly overriding explicit config.\n3.0 is the API Platform you\u0026rsquo;d design from scratch knowing what you know after years of 2.x. The price is the migration. The version number is honest about that.\n","permalink":"https://guillaumedelre.github.io/2022/11/18/api-platform-3.0-a-new-state-model-and-the-end-of-dataproviders/","summary":"\u003cp\u003eAPI Platform 3.0 arrived in September 2022 with Symfony 6.1 as a hard minimum and a core architecture that looked nothing like 2.x. The migration guide is long. The reason it\u0026rsquo;s long is interesting.\u003c/p\u003e\n\u003cp\u003eThe old model had a conceptual leak. \u003ccode\u003eDataProviderInterface\u003c/code\u003e and \u003ccode\u003eDataPersisterInterface\u003c/code\u003e were called for every HTTP request, but the provider received the operation context as a hint — not as a contract. A collection provider and an item provider were separate interfaces, but both lived in the same mental bucket: \u0026ldquo;things that return data.\u0026rdquo; The HTTP layer knew what was being requested; the provider had to reconstruct that knowledge from context clues passed in the \u003ccode\u003e$context\u003c/code\u003e array.\u003c/p\u003e","title":"API Platform 3.0: a new state model and the end of DataProviders"},{"content":"I ran Vagrant for years. A Vagrantfile per project, a shared base box, a provision script that worked on Tuesday but not on Thursday. The promise was simple: reproducible environments for everyone on the team. The reality was more complicated.\nThe Vagrant years The setup made sense at the time. One VM per project, provisioned with shell scripts or Ansible, shared via a versioned Vagrantfile. Onboarding was theoretically vagrant up and you\u0026rsquo;re done.\nIn practice, it was vagrant up, wait four minutes, watch the provision fail on a package that changed its download URL, fix it, reprovision, wait again. Vagrantfiles accumulated configuration over time: workarounds for specific machines, OS version pinning, memory tweaks for the team member whose laptop had 8GB. The files became historical documents nobody wanted to touch.\nThe VM itself was the other problem. Booting took time. Running took memory and CPU that could have gone to the application. File syncing between host and guest added latency that made PHP apps feel slower than they had any right to be. The overhead was significant for what was ultimately just \u0026ldquo;run a web server.\u0026rdquo;\nWe lived with it because everyone did. Vagrant was the standard for local PHP development, and the alternative (each developer managing their own LAMP stack) was clearly worse.\nThe project that changed the model The shift wasn\u0026rsquo;t a decision we made. It was a project that arrived already containerized.\nA new client project had a docker-compose.yml at the root, a Dockerfile, and a README that said docker compose up. We ran it. The containers started in seconds. PHP-FPM, nginx, PostgreSQL, Redis: all running, all networked, no provisioning step. Stop the containers, start them again, same state.\nThe contrast with our Vagrant setup was immediate. Not faster by a percentage: faster by a different order. And the Compose file was actually readable: each service, its image, its volumes, its environment variables, its dependencies. Compared to a provision script that SSHed into a VM and ran apt-get, this was legible.\nWe migrated everything. Not gradually, all at once, over a sprint. Every project got a docker-compose.yml. Every Vagrantfile was deleted. The transition was the most painful three weeks of infrastructure work I remember, and also the most clearly worth it.\nWhat docker-compose actually changed Beyond the speed, Compose changed the mental model. Vagrant abstracted a machine. Compose abstracted a set of processes. The distinction matters: with Compose, you can stop the database without stopping the application server, scale a worker service independently, swap the PostgreSQL image for a newer version without touching anything else.\nThe services declaration also replaced the VM provisioning problem entirely. If a new developer joins, they don\u0026rsquo;t run a provision script that may or may not work on their OS version. They run docker compose up and get the exact same images everyone else runs.\nCI/CD got simpler too. The same docker-compose.yml that ran locally could run in the pipeline. The environment parity that Vagrant promised but rarely delivered was actually real with Compose.\nThe quiet deprecation For years, the command was docker-compose: a separate binary, installed independently from Docker itself, written in Python, versioned independently. We used it, it worked, nobody thought much about it.\nAt some point a colleague mentioned that Docker had integrated Compose directly into the docker CLI. The new command was docker compose, no hyphen, Go rewrite, bundled with Docker Desktop. The old docker-compose binary was deprecated.\nWe had been using v1 for two years after v2 shipped. Our CI scripts, our Makefiles, our documentation all said docker-compose. Nothing had broken because Docker maintained the old binary for a long time. But the ecosystem had moved on quietly, and we\u0026rsquo;d missed it.\nThe migration was trivial: a hyphen removed from every script, a few aliases updated. The lesson was less trivial. Infrastructure tooling evolves without ceremony. The announcement happened, the blog posts were written, the deprecation notices were there. We just weren\u0026rsquo;t paying attention.\nThe actual retrospective Looking back across Vagrant → docker-compose → docker compose, the pattern is less about the tools and more about the defaults.\nVagrant defaulted to \u0026ldquo;it works on my VM.\u0026rdquo; The overhead of sharing that VM was permanent.\nCompose defaulted to \u0026ldquo;it works in these containers.\u0026rdquo; The images are the artifacts; the host machine is irrelevant.\nThe hyphen between docker and compose was always cosmetic. What mattered was the shift from provisioned machines to declarative services. That shift happened the day we ran a project someone else containerized and realized we never wanted to go back.\n","permalink":"https://guillaumedelre.github.io/2022/04/18/from-vagrant-to-docker-compose-a-retrospective/","summary":"\u003cp\u003eI ran Vagrant for years. A Vagrantfile per project, a shared base box, a provision script that worked on Tuesday but not on Thursday. The promise was simple: reproducible environments for everyone on the team. The reality was more complicated.\u003c/p\u003e\n\u003ch2 id=\"the-vagrant-years\"\u003eThe Vagrant years\u003c/h2\u003e\n\u003cp\u003eThe setup made sense at the time. One VM per project, provisioned with shell scripts or Ansible, shared via a versioned Vagrantfile. Onboarding was theoretically \u003ccode\u003evagrant up\u003c/code\u003e and you\u0026rsquo;re done.\u003c/p\u003e","title":"From Vagrant to Docker Compose: a retrospective"},{"content":"We migrated a media microservices platform to Symfony 6 at the start of 2022. Twelve services, most of them consuming messages from RabbitMQ via Swarrot. Symfony 6 made Messenger more central than ever, and during the migration planning a developer asked the obvious question: why not switch at the same time?\nIt ships with the framework. It has retry logic, native AMQP support, first-party documentation. Our setup looked artisanal by comparison.\nFair question. We took it seriously. Here\u0026rsquo;s what we found.\nWiring the topology by hand Swarrot is a consumer library that wraps the PECL AMQP extension. It reads bytes from a queue, runs them through a chain of processors (their term for middleware), and lets your code decide what to do with the payload. That\u0026rsquo;s really it.\nThe middleware chain is the interesting part. Processors are nested decorators, each wrapping the next. The outer layers handle infrastructure concerns before the message even reaches your business logic:\nmiddleware_stack: - configurator: \u0026#39;swarrot.processor.signal_handler\u0026#39; - configurator: \u0026#39;swarrot.processor.max_execution_time\u0026#39; - configurator: \u0026#39;swarrot.processor.exception_catcher\u0026#39; - configurator: \u0026#39;swarrot.processor.doctrine_object_manager\u0026#39; - configurator: \u0026#39;swarrot.processor.ack\u0026#39; - configurator: \u0026#39;app.processor.retry\u0026#39; signal_handler sits at the top because it needs to catch SIGTERM before any other processor sees it. ack sits near the bottom because you only acknowledge the message after processing succeeds. The order is not arbitrary, and it\u0026rsquo;s entirely visible in configuration.\nThe topology is equally explicit. You declare everything yourself: exchanges, routing keys, retry queues, dead-letter queues:\nmessages_types: content.ingest: exchange: e.app.content routing_key: q.app.content.ingest content.ingest_retry: exchange: e.app.content routing_key: q.app.content.ingest.retry content.ingest_dead: exchange: e.app.content routing_key: q.app.content.ingest.dead Three entries per logical message type: main queue, retry queue, dead-letter queue. Everything that exists on the broker is named right here. The config is verbose but honest: no inference, no convention over configuration. If a queue exists in RabbitMQ, you can trace it to a single line of YAML.\nWhen the class name becomes the route Symfony Messenger operates one level higher. You define a message class, a handler, and a transport. The library handles serialization, routing, retry, and failure queues automatically.\nclass IngestContent { public function __construct( public readonly string $contentId, public readonly string $source, ) {} } framework: messenger: transports: async: dsn: \u0026#39;%env(MESSENGER_TRANSPORT_DSN)%\u0026#39; retry_strategy: max_retries: 3 delay: 1000 routing: \u0026#39;App\\Message\\IngestContent\u0026#39;: async Messenger serializes the object, puts it on the transport, and deserializes it on the other end into the correct class. No manual topology, no explicit exchange names. The class name is the routing primitive.\nThat last sentence is exactly where things got complicated for us.\nWhere typing becomes coupling Messenger assumes that the producer and the consumer share a PHP class definition. That\u0026rsquo;s fine for a single app, or for services that share a dedicated contracts package. In a monorepo of independent Symfony applications, it creates coupling that simply doesn\u0026rsquo;t exist today.\nTake a content ingestion message that twelve services consume. With Swarrot, each service reads the raw JSON payload and picks the fields it cares about. Adding a new field means updating the producer. Consumers that don\u0026rsquo;t need the field keep working without any modification.\nWith Messenger, IngestContent must be defined somewhere that all twelve services can reference. That means either:\nA shared PHP package, versioned, deployed, and maintained across services. Every schema change becomes a cross-service coordination exercise. Duplicated classes in each service, which drift silently apart under pressure. Neither is free. The shared package approach inverts the ownership model: the message schema becomes a dependency rather than a contract defined at the boundary. The duplication approach is just the original problem deferred.\nThe root difference is what a message represents. Messenger is designed for typed commands: an object that carries meaning and dispatches to a specific handler. Swarrot treats messages as opaque data: bytes that flow through a topology, processed by whatever consumer happens to be listening. If your messages are data, the extra abstraction Messenger adds doesn\u0026rsquo;t help you. It creates friction.\nThe blocker The serialization problem was the decisive one. In a monorepo where services are autonomous, sharing PHP classes between them isn\u0026rsquo;t architecturally neutral: it\u0026rsquo;s a coupling decision that makes future changes harder. We would have been trading a nominally \u0026ldquo;legacy\u0026rdquo; library for a more modern one while introducing exactly the kind of tight coupling we\u0026rsquo;d spent years avoiding.\nThere were secondary concerns too. The PECL AMQP extension gives direct access to broker features (message priorities, per-queue TTL, headers exchange routing) that Messenger abstracts away. And migrating fifteen consumers without a flag day means running both libraries in parallel, which is a real operational constraint.\nBut the serialization issue alone would have been enough.\nData or commands: that\u0026rsquo;s the question The choice isn\u0026rsquo;t about library quality. Messenger is well-maintained, well-documented, and integrates cleanly into the Symfony ecosystem.\nThe question to ask first is: what are your messages?\nIf they are typed commands with a known schema and a single authoritative consumer, Messenger is a natural fit. You write a class, a handler, configure a transport, and the infrastructure handles the rest.\nIf they are data payloads consumed by multiple independent services, each of which owns its own deserialization, the abstraction Messenger adds works against you. Swarrot\u0026rsquo;s explicit topology and raw payload model give you more control where you actually need it.\nOne real limitation to keep in mind: Swarrot is tied to the PECL AMQP extension, which only implements AMQP 0-9-1. That means RabbitMQ (or a compatible broker) is a hard dependency. If your infrastructure ever moves toward an AMQP 1.0 broker (Azure Service Bus, ActiveMQ Artemis), Swarrot can\u0026rsquo;t follow. Messenger\u0026rsquo;s transport layer abstracts this cleanly: changing brokers means changing a DSN, not rewriting consumers.\nIf broker portability is a requirement, or likely to become one, that changes the calculus significantly.\nSwarrot isn\u0026rsquo;t legacy to migrate away from. For now, it\u0026rsquo;s the right fit: AMQP routing as the primitive, messages as data, RabbitMQ as a long-term infrastructure choice.\nThat could change. A shared contracts package, a new broker requirement, a greenfield service that doesn\u0026rsquo;t carry the existing topology weight: any of these could tip the balance toward Messenger. The library isn\u0026rsquo;t wrong for this platform. It may just be the right answer for a future version of it.\n","permalink":"https://guillaumedelre.github.io/2022/01/26/swarrot-vs-symfony-messenger-a-real-world-comparison/","summary":"\u003cp\u003eWe migrated a media microservices platform to Symfony 6 at the start of 2022. Twelve services, most of them consuming messages from RabbitMQ via \u003ca href=\"https://github.com/swarrot/swarrot\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eSwarrot\u003c/a\u003e. Symfony 6 made \u003ca href=\"https://symfony.com/doc/current/messenger.html\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eMessenger\u003c/a\u003e more central than ever, and during the migration planning a developer asked the obvious question: why not switch at the same time?\u003c/p\u003e\n\u003cp\u003eIt ships with the framework. It has retry logic, native AMQP support, first-party documentation. Our setup looked artisanal by comparison.\u003c/p\u003e","title":"Swarrot vs Symfony Messenger: a real-world comparison"},{"content":"Symfony 6.0 released November 29, 2021. The defining characteristic: PHP 8.1 is the minimum. Not supported, required. The releases team waited for PHP 8.1 to ship, then cut Symfony 6.0 the next day.\nThis isn\u0026rsquo;t just a version bump. It\u0026rsquo;s a commitment to build against the current language instead of the historical floor.\nThe security system, finally rebuilt The Symfony security component has two systems. The old one (AnonymousToken, GuardAuthenticatorInterface, a tangle of interfaces that made you implement methods you didn\u0026rsquo;t need) had been deprecated. 6.0 removes it entirely.\nThe new security system (security.enable_authenticator_manager: true in 5.x) is now the only system. It\u0026rsquo;s cleaner: one interface to implement, clear separation between authentication and authorization, passport-based credential checking. The upgrade from the old guard authenticators isn\u0026rsquo;t painless, but the destination is a lot less confusing.\nThe Filesystem Path class Working with filesystem paths in PHP is basically a string manipulation problem. __DIR__, concatenation, realpath(), platform-specific separators: the standard library gives you primitives but no real model.\nThe new Path class handles this:\nuse Symfony\\Component\\Filesystem\\Path; Path::join(\u0026#39;/var/www\u0026#39;, \u0026#39;html\u0026#39;, \u0026#39;../uploads\u0026#39;); // /var/www/uploads Path::makeRelative(\u0026#39;/var/www/html\u0026#39;, \u0026#39;/var/www\u0026#39;); // html Path::isAbsolute(\u0026#39;./relative/path\u0026#39;); // false Cross-platform, no side effects, no filesystem access needed. Also in 6.0: nested .gitignore pattern support in Finder.\nEnums in the form system Building on 5.4\u0026rsquo;s groundwork, 6.0 takes enum support further. BackedEnum values round-trip through forms and the serializer without custom transformers. The form component understands enum cases as choice options out of the box.\nWhat 6.0 removes The removal list is extensive: the old security system, the Templating component, PHP annotations support (replaced by native attributes), Doctrine Cache support, ContainerAwareTrait. Six years of accumulated @deprecated markers, finally cleaned out.\nApps that took 5.4 deprecation warnings seriously had a clean upgrade path. Apps that didn\u0026rsquo;t had work to do.\nTab completion was always the gap The Console component got shell autocompletion, and it\u0026rsquo;s properly integrated: define a complete() method on your command, and Tab in Bash will suggest valid values for options and arguments.\nclass DeployCommand extends Command { public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input-\u0026gt;mustSuggestOptionValuesFor(\u0026#39;env\u0026#39;)) { $suggestions-\u0026gt;suggestValues([\u0026#39;prod\u0026#39;, \u0026#39;staging\u0026#39;, \u0026#39;dev\u0026#39;]); } } } All built-in Symfony commands got completion too: debug:router, cache:pool:clear, lint:yaml, and about fifteen others. Run bin/console completion bash \u0026gt;\u0026gt; ~/.bashrc and you\u0026rsquo;re done.\nMessenger, now with attributes and batch processing The #[AsMessageHandler] attribute replaces the old MessageHandlerInterface. Less boilerplate, and you can now configure transport affinity and priority directly on the attribute:\n#[AsMessageHandler(fromTransport: \u0026#39;async\u0026#39;, priority: 10)] class SendWelcomeEmailHandler { public function __invoke(UserRegistered $message): void { ... } } The other significant addition: BatchHandlerInterface. When you\u0026rsquo;re inserting a thousand rows, handling messages one by one is wasteful. Batch handlers collect messages and process them in groups. The default batch size is 10, controlled by BatchHandlerTrait::shouldFlush(). The Acknowledger handles individual success and failure within the batch.\nreset_on_message: true in the Messenger config resets container services between messages. Previously, a Monolog buffer could fill up across message handling and nobody noticed until production. This prevents that class of statefulness bug without requiring manual cleanup.\nThe DI container gets more expressive Three changes that matter in practice.\nUnion and intersection types now autowire. PHP 8.1 added intersection types, and Symfony 6.0 wires them:\npublic function __construct( private NormalizerInterface\u0026amp;DenormalizerInterface $serializer ) {} This works as long as both interfaces point to the same service through autowiring aliases.\nTaggedIterator and TaggedLocator attributes gained defaultPriorityMethod and defaultIndexMethod options. You no longer need YAML to express ordering or indexing for tagged services:\npublic function __construct( #[TaggedIterator(tag: \u0026#39;app.handler\u0026#39;, defaultPriorityMethod: \u0026#39;getPriority\u0026#39;)] private iterable $handlers, ) {} SubscribedService (the attribute that replaces the implicit magic of ServiceSubscriberTrait) makes lazy service access explicit and typeable:\n#[SubscribedService] private function mailer(): MailerInterface { return $this-\u0026gt;container-\u0026gt;get(__METHOD__); } Validation gets three new tools CssColor validates CSS color values in whatever formats you care about: hex, RGB, HSL, named colors, or any mix. Useful for theme config fields where you want to accept #ff0000 but not red, or vice versa.\n#[Assert\\CssColor(formats: Assert\\CssColor::HEX_LONG)] private string $brandColor; Cidr validates CIDR notation for IPv4 and IPv6, with options to pin the version and constrain the netmask range. Infrastructure tools and network config forms finally have a first-class constraint.\nThe third addition isn\u0026rsquo;t a new constraint. It\u0026rsquo;s PHP 8.1 nested attributes making existing compound constraints usable without XML. AtLeastOneOf, Collection, All, Sequentially: all of these previously required annotation workarounds. Now they just work as attributes:\n#[Assert\\Collection( fields: [ \u0026#39;email\u0026#39; =\u0026gt; new Assert\\Email(), \u0026#39;role\u0026#39; =\u0026gt; [new Assert\\NotBlank(), new Assert\\Choice([\u0026#39;admin\u0026#39;, \u0026#39;user\u0026#39;])], ] )] private array $payload; Serializer, cleaned up Two things. First, serialization context is now configurable globally instead of being repeated on every serialize() call:\n# config/packages/serializer.yaml serializer: default_context: enable_max_depth: true Second, the COLLECT_DENORMALIZATION_ERRORS option changes how the serializer handles type errors on deserialization. Instead of throwing on the first problem, it collects all of them and surfaces them through PartialDenormalizationException. If you\u0026rsquo;re writing an API that deserializes request bodies, this is the difference between returning \u0026ldquo;first field that fails\u0026rdquo; and \u0026ldquo;all fields that fail\u0026rdquo; in a single response.\nThe string utilities nobody knew they needed trimPrefix() and trimSuffix() on the UnicodeString / ByteString classes. Not glamorous, but stripping a known prefix with ltrim() is a subtle footgun: it strips characters, not strings. These are correct:\nuse function Symfony\\Component\\String\\u; u(\u0026#39;file-image-001.png\u0026#39;)-\u0026gt;trimPrefix(\u0026#39;file-\u0026#39;); // \u0026#39;image-001.png\u0026#39; u(\u0026#39;report.html.twig\u0026#39;)-\u0026gt;trimSuffix(\u0026#39;.twig\u0026#39;); // \u0026#39;report.html\u0026#39; Also in this release: NilUlid for zero-value ULIDs, perMonth() and perYear() on RateLimiter for when hourly limits don\u0026rsquo;t make sense, and appendToFile() in the Filesystem component gained an optional LOCK_EX parameter for concurrent writers.\nDebugging the environment debug:dotenv is a new console command that shows which .env files were loaded and where each value came from. When you have .env, .env.local, .env.test, and .env.test.local all fighting each other and something is wrong, this command tells you exactly which file won. It only shows up when the Dotenv component is in use, which is the case for any standard Symfony app.\n","permalink":"https://guillaumedelre.github.io/2022/01/12/symfony-6.0-php-8.1-only-and-the-security-system-rebuilt/","summary":"\u003cp\u003eSymfony 6.0 released November 29, 2021. The defining characteristic: PHP 8.1 is the minimum. Not supported, required. The releases team waited for PHP 8.1 to ship, then cut Symfony 6.0 the next day.\u003c/p\u003e\n\u003cp\u003eThis isn\u0026rsquo;t just a version bump. It\u0026rsquo;s a commitment to build against the current language instead of the historical floor.\u003c/p\u003e\n\u003ch2 id=\"the-security-system-finally-rebuilt\"\u003eThe security system, finally rebuilt\u003c/h2\u003e\n\u003cp\u003eThe Symfony security component has two systems. The old one (\u003ccode\u003eAnonymousToken\u003c/code\u003e, \u003ccode\u003eGuardAuthenticatorInterface\u003c/code\u003e, a tangle of interfaces that made you implement methods you didn\u0026rsquo;t need) had been deprecated. 6.0 removes it entirely.\u003c/p\u003e","title":"Symfony 6.0: PHP 8.1 only, and the security system rebuilt"},{"content":"Symfony 5.4 landed November 29, 2021, same day as Symfony 6.0 and one day after PHP 8.1 was released. Not a coincidence.\n5.4 is the LTS, and its job is to carry as much of 6.0\u0026rsquo;s feature set as possible while keeping 5.x compatibility intact. It\u0026rsquo;s also the first Symfony release that actually understands PHP 8.1 features.\nEnum support PHP 8.1 introduced native enums. Symfony 5.4 embraces them immediately:\nenum Status: string { case Active = \u0026#39;active\u0026#39;; case Inactive = \u0026#39;inactive\u0026#39;; } The EnumType form type renders enums as select fields, no custom transformers needed. The validator understands backed enums. The serializer maps enum values to their backing type and back. Three components updated in one shot, which meant migrating codebases from pseudo-enum constants to real PHP 8.1 enums was actually pretty smooth.\nSecurity voter cache The CacheableVoterInterface lets voters that always abstain on a given attribute signal that to the security system, which can then skip them on subsequent checks. For apps with many voters, the gain on permission checks adds up fast. Small change, noticeable in practice.\nMessenger matures further Messenger batch processing (handling multiple messages in a single transaction instead of one by one) is now stable. Rate limiting per transport. Dead letter queues get better tooling. After years as \u0026ldquo;experimental\u0026rdquo;, Messenger in 5.4 is finally the async foundation you can bet on for serious workloads.\nConsole grew a tab key Symfony 5.4 ships shell autocompletion for all commands. Press Tab and the shell suggests command names, argument values, and option values. For built-in commands this works out of the box. For custom commands, add a complete() method:\nuse Symfony\\Component\\Console\\Completion\\CompletionInput; use Symfony\\Component\\Console\\Completion\\CompletionSuggestions; public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input-\u0026gt;mustSuggestOptionValuesFor(\u0026#39;format\u0026#39;)) { $suggestions-\u0026gt;suggestValues([\u0026#39;json\u0026#39;, \u0026#39;xml\u0026#39;, \u0026#39;csv\u0026#39;]); } } No interface required, just the method and Symfony picks it up. The community also went through all built-in commands (debug:router, cache:pool:clear, secrets:remove, lint:twig, and a dozen more) to add completions before the release.\nRoutes can be aliases now The routing component now supports aliasing: one route can point to another. The obvious use case is renaming a route without breaking anything that still generates URLs with the old name.\n# config/routes.yaml admin_dashboard: path: /admin # legacy name kept during transition dashboard: alias: admin_dashboard deprecated: package: \u0026#39;acme/my-bundle\u0026#39; version: \u0026#39;2.3\u0026#39; Generating a URL with dashboard still works, but fires a deprecation notice. Clean rename paths for bundles that need to maintain public route names while moving on.\nExceptions map to HTTP status codes in config Before 5.4, mapping an exception class to an HTTP status code meant implementing HttpExceptionInterface or writing a listener. Now it\u0026rsquo;s just a YAML entry:\n# config/packages/framework.yaml framework: exceptions: App\\Exception\\PaymentRequiredException: status_code: 402 log_level: warning App\\Exception\\MaintenanceException: status_code: 503 log_level: info The exception doesn\u0026rsquo;t need to implement anything. The framework reads the map, sets the status code, logs at the configured level. Handy for domain exceptions that have no business knowing about HTTP.\nTwo new validator constraints 5.4 adds Cidr and CssColor to the Validator component.\nCidr validates network notation — IP address plus subnet mask — with control over which IP version to accept and bounds on the mask value:\n#[Assert\\Cidr(version: 4, netmaskMin: 16, netmaskMax: 28)] private string $allowedSubnet; CssColor validates that a string is a valid CSS color. Useful for theme editors, CMS config, or any UI that lets users pick colors:\n#[Assert\\CssColor( formats: Assert\\CssColor::HEX_LONG, message: \u0026#39;The accent color must be a 6-digit hex value.\u0026#39;, )] private string $accentColor; Nested PHP attributes for validation constraints Symfony 5.2 added validator constraints as PHP attributes, but PHP 8.0 had a hard limit on nested attributes. Complex constraints like All, Collection, or AtLeastOneOf were impossible to express in attribute syntax alone. PHP 8.1 lifted that restriction, and 5.4 makes the most of it:\nuse Symfony\\Component\\Validator\\Constraints as Assert; class CartItem { #[Assert\\All([ new Assert\\NotNull(), new Assert\\Range(min: 1), ])] private array $quantities; #[Assert\\AtLeastOneOf( constraints: [new Assert\\Email(), new Assert\\Url()], message: \u0026#39;Must be a valid email or URL.\u0026#39;, )] private string $contact; } No annotation doc-blocks, no XML mapping. Pure PHP 8.1 attributes all the way down.\nDependency injection: three things worth knowing Tagged iterators can now be injected into service locators, which previously only accepted explicit service lists. Union type autowiring works when both sides of the union resolve to the same service, which is common with serializer interfaces:\npublic function __construct( private NormalizerInterface \u0026amp; DenormalizerInterface $serializer ) {} #[SubscribedService] replaces the automatic introspection that ServiceSubscriberTrait did implicitly. It\u0026rsquo;s now an explicit attribute on methods, which makes the dependency visible without any magic:\nuse Symfony\\Contracts\\Service\\Attribute\\SubscribedService; class SomeService implements ServiceSubscriberInterface { #[SubscribedService] private function router(): RouterInterface { return $this-\u0026gt;container-\u0026gt;get(__METHOD__); } } Messenger: attributes, worker state, and service reset Messenger handlers can drop the MessageHandlerInterface in favor of #[AsMessageHandler], which also lets you bind a handler to a specific transport and set its priority, all without touching YAML:\n#[AsMessageHandler(fromTransport: \u0026#39;async\u0026#39;, priority: 10)] class ProcessOrderHandler { public function __invoke(ProcessOrder $message): void { /* ... */ } } Worker state is now inspectable via WorkerMetadata inside event listeners, useful when you have workers on multiple transports and need to know which one fired a given event.\nLong-running workers accumulate state across messages: entity manager buffers, in-memory caches, open connections. The new reset_on_message option takes care of resetting all resettable services between messages:\nframework: messenger: reset_on_message: true Serializer: collect errors instead of throwing Deserializing external JSON into a typed DTO used to throw on the very first type mismatch. The COLLECT_DENORMALIZATION_ERRORS option changes that: all type errors get collected into a PartialDenormalizationException, so you can return a proper 400 with a full list of field-level problems:\ntry { $dto = $serializer-\u0026gt;deserialize($request-\u0026gt;getContent(), OrderDto::class, \u0026#39;json\u0026#39;, [ DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS =\u0026gt; true, ]); } catch (PartialDenormalizationException $e) { return $this-\u0026gt;json( array_map(fn($err) =\u0026gt; [\u0026#39;path\u0026#39; =\u0026gt; $err-\u0026gt;getPath(), \u0026#39;expected\u0026#39; =\u0026gt; $err-\u0026gt;getExpectedTypes()], $e-\u0026gt;getErrors()), 400 ); } The serializer\u0026rsquo;s default context can also be set globally in YAML, so you stop passing the same options on every call.\nLanguage negotiation out of the box Two new framework options handle the Accept-Language header without custom listeners:\nframework: enabled_locales: [\u0026#39;en\u0026#39;, \u0026#39;fr\u0026#39;, \u0026#39;de\u0026#39;] set_locale_from_accept_language: true set_content_language_from_locale: true With this in place, Symfony reads the browser\u0026rsquo;s preferred language, picks the best match from enabled_locales, sets the request locale, and adds a Content-Language header to the response. The {_locale} route attribute still takes precedence when present.\nTranslation: extraction, not update The translation:update command is renamed to translation:extract. The old name sticks around as deprecated. The distinction matters: the command never writes to a database, it extracts translatable strings from source files. The new name finally says what it does.\nlint:xliff also gains a --format=github option that outputs errors as GitHub Actions annotations, so translation lint failures show up as PR review comments instead of getting buried in log output.\nController shortcuts pruned Three AbstractController shortcuts are deprecated: getDoctrine(), dispatchMessage(), and the generic get() method for pulling arbitrary services from the container. The direction is explicit constructor injection. For getDoctrine() specifically:\n// before $em = $this-\u0026gt;getDoctrine()-\u0026gt;getManager(); // after — inject it directly public function __construct(private EntityManagerInterface $em) {} Request::get() is also deprecated. It searched route attributes, query string, and POST body in an undocumented order, which was a great way to get surprising results. Use $request-\u0026gt;query-\u0026gt;get(), $request-\u0026gt;request-\u0026gt;get(), or $request-\u0026gt;attributes-\u0026gt;get() and be explicit about where the value comes from.\nThe Path utility class The Filesystem component gets a Path class ported from webmozart/path-util. It handles the awkward cases that dirname() and realpath() fumble:\nuse Symfony\\Component\\Filesystem\\Path; Path::canonicalize(\u0026#39;../config/../config/services.yaml\u0026#39;); // \u0026#39;../config/services.yaml\u0026#39; Path::getDirectory(\u0026#39;C:/\u0026#39;); // \u0026#39;C:/\u0026#39; (dirname() returns \u0026#39;.\u0026#39;) Path::getLongestCommonBasePath([ \u0026#39;/var/www/project/src/Controller/FooController.php\u0026#39;, \u0026#39;/var/www/project/src/Controller/BarController.php\u0026#39;, \u0026#39;/var/www/project/src/Entity/User.php\u0026#39;, ]); // \u0026#39;/var/www/project/src\u0026#39; Useful whenever your code deals with paths that cross OS boundaries or involve relative segments.\nSmaller things that add up debug:dotenv shows which .env files were loaded and what value each variable resolves to. The first thing you reach for when environment-specific behavior is acting up.\nThe String component adds trimPrefix() and trimSuffix() for removing known prefixes or suffixes without writing a substr calculation:\nu(\u0026#39;file-image-0001.png\u0026#39;)-\u0026gt;trimPrefix(\u0026#39;file-\u0026#39;); // \u0026#39;image-0001.png\u0026#39; u(\u0026#39;template.html.twig\u0026#39;)-\u0026gt;trimSuffix(\u0026#39;.twig\u0026#39;); // \u0026#39;template.html\u0026#39; DomCrawler gets innerText(), which returns only the direct text of a node, excluding child elements. text() returns everything including nested text; innerText() returns just the node\u0026rsquo;s own content. Small difference, but it matters when scraping.\nThe RateLimiter component extends its interval support to perMonth() and perYear(), for apps that need to limit events over longer windows: newsletter sends, API quota resets, annual plan limits.\nThe Finder component now respects .gitignore files in all subdirectories when you call ignoreVCSIgnored(true), not just the root. Child directory rules override parent rules, exactly like git itself.\nThe LTS window 5.4 gets bug fixes until November 2024 and security fixes until November 2025. The migration from 5.4 to 6.4 (the next LTS) is intentionally smooth: fix the 5.4 deprecation warnings, and the 6.x jump is mechanical.\nThe deprecation layer in 5.4 points at everything 6.0 removes: the remaining pieces of the old security system, ContainerAwareTrait, and a handful of legacy form and serializer patterns.\n","permalink":"https://guillaumedelre.github.io/2022/01/10/symfony-5.4-lts-enum-support-route-aliases-and-the-php-8.1-bridge/","summary":"\u003cp\u003eSymfony 5.4 landed November 29, 2021, same day as Symfony 6.0 and one day after PHP 8.1 was released. Not a coincidence.\u003c/p\u003e\n\u003cp\u003e5.4 is the LTS, and its job is to carry as much of 6.0\u0026rsquo;s feature set as possible while keeping 5.x compatibility intact. It\u0026rsquo;s also the first Symfony release that actually understands PHP 8.1 features.\u003c/p\u003e\n\u003ch2 id=\"enum-support\"\u003eEnum support\u003c/h2\u003e\n\u003cp\u003ePHP 8.1 introduced native enums. Symfony 5.4 embraces them immediately:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eenum\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eStatus\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eActive\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;active\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eInactive\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;inactive\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe \u003ccode\u003eEnumType\u003c/code\u003e form type renders enums as select fields, no custom transformers needed. The validator understands backed enums. The serializer maps enum values to their backing type and back. Three components updated in one shot, which meant migrating codebases from pseudo-enum constants to real PHP 8.1 enums was actually pretty smooth.\u003c/p\u003e","title":"Symfony 5.4 LTS: enum support, route aliases, and the PHP 8.1 bridge"},{"content":"PHP 8.1 released November 25th. It follows 8.0\u0026rsquo;s sweeping overhaul with something different: fewer features, but each one thought through rather than bolted on.\nEnums This is the one that changes codebases the moment you upgrade. Before 8.1, PHP enumerations were either class constants, strings, or integers with nothing enforcing them:\n// before: nothing stops Status::INVALID from being passed const ACTIVE = \u0026#39;active\u0026#39;; const INACTIVE = \u0026#39;inactive\u0026#39;; // after enum Status: string { case Active = \u0026#39;active\u0026#39;; case Inactive = \u0026#39;inactive\u0026#39;; } function activate(Status $status): void { ... } PHP enums are objects, not scalars. They support methods, interfaces, and constants. Backed enums (with a string or int value) serialize cleanly and map to database columns naturally. Pure enums (no backing type) enforce domain concepts without worrying about serialization.\nThe immediate effect: every status field, every finite set of states in every codebase I maintain became an enum candidate. The type system finally has a native way to express the thing every PHP project has been faking for years.\nFibers Fibers are a cooperative concurrency primitive: you can pause and resume execution of a function, yielding control without threads.\n$fiber = new Fiber(function(): void { $value = Fiber::suspend(\u0026#39;first\u0026#39;); echo \u0026#34;Resumed with: {$value}\\n\u0026#34;; }); $result = $fiber-\u0026gt;start(); // \u0026#39;first\u0026#39; $fiber-\u0026gt;resume(\u0026#39;hello\u0026#39;); // \u0026#34;Resumed with: hello\u0026#34; Fibers are the foundation async libraries like ReactPHP and Amp have needed from the runtime for a while. For most application developers the direct API matters less than the libraries built on top of it, but understanding fibers explains what those libraries are doing underneath.\n:pencil2: Readonly properties 8.0 brought constructor promotion. 8.1 adds readonly:\nclass User { public function __construct( public readonly int $id, public readonly string $name, ) {} } A readonly property can be written exactly once, during initialization. After that, any write throws an Error. Combined with constructor promotion, value objects and DTOs become concise and actually mean what they say.\nFirst-class callable syntax $fn = strlen(...); $fn = $this-\u0026gt;process(...); $fn = MyClass::create(...); ... after a callable creates a Closure without Closure::fromCallable() boilerplate. Useful when passing methods as callbacks.\n8.1 is precise. Enums alone justify the upgrade.\nIntersection types Union types landed in 8.0. Intersection types follow in 8.1. Where a union says \u0026ldquo;one of these\u0026rdquo;, an intersection says \u0026ldquo;all of these\u0026rdquo;:\nfunction process(Countable\u0026amp;Iterator $collection): void { foreach ($collection as $item) { /* ... */ } echo count($collection); } One constraint: intersection types can\u0026rsquo;t be mixed with union types in the same declaration (that arrives in 8.2 as DNF types). But this already unlocks precise type-checking for objects that must satisfy multiple interfaces at once, a pattern frameworks use constantly that had to stay untyped until now.\nThe never return type A function that never returns (it always throws or exits) now has a type to say so:\nfunction redirect(string $url): never { header(\u0026#34;Location: {$url}\u0026#34;); exit(); } function fail(string $message): never { throw new \\RuntimeException($message); } The practical benefit: static analyzers can prove that code after a never function is unreachable, and callers know there\u0026rsquo;s no return value to handle. Before this, it lived in docblocks with no enforcement.\nFinal class constants Before 8.1, any subclass could quietly override a parent\u0026rsquo;s class constant. Now you can put a stop to that:\nclass Base { final public const VERSION = \u0026#39;1.0\u0026#39;; } class Child extends Base { // Fatal error: Cannot override final constant Base::VERSION public const VERSION = \u0026#39;2.0\u0026#39;; } Relatedly, interface constants are now overridable by implementing classes by default. A separate behavior fix that had been inconsistent since interfaces were introduced.\nnew in initializers Default parameter values used to be restricted to scalars and arrays. 8.1 drops that restriction:\nclass Logger { public function __construct( private Handler $handler = new NullHandler(), ) {} } function createUser( Validator $validator = new DefaultValidator(), ): User { /* ... */ } Same goes for attribute arguments and static variable initializers. Which means dependency injection with sensible defaults no longer needs a null check and lazy instantiation inside the method body.\nArray unpacking with string keys Array unpacking via the spread operator only worked with integer-keyed arrays before 8.1. String keys work now too:\n$defaults = [\u0026#39;color\u0026#39; =\u0026gt; \u0026#39;red\u0026#39;, \u0026#39;size\u0026#39; =\u0026gt; \u0026#39;M\u0026#39;]; $custom = [\u0026#39;size\u0026#39; =\u0026gt; \u0026#39;L\u0026#39;, \u0026#39;weight\u0026#39; =\u0026gt; \u0026#39;200g\u0026#39;]; $merged = [...$defaults, ...$custom]; // [\u0026#39;color\u0026#39; =\u0026gt; \u0026#39;red\u0026#39;, \u0026#39;size\u0026#39; =\u0026gt; \u0026#39;L\u0026#39;, \u0026#39;weight\u0026#39; =\u0026gt; \u0026#39;200g\u0026#39;] Later keys override earlier ones. Same behavior as array_merge(), but expressed inline. Performance difference is marginal; readability difference is not.\nfsync and fdatasync Two functions that had no good reason to be missing from a filesystem-oriented language:\n$fp = fopen(\u0026#39;/tmp/important.dat\u0026#39;, \u0026#39;w\u0026#39;); fwrite($fp, $data); fsync($fp); // flush OS buffers to physical storage fclose($fp); fdatasync() does the same but skips metadata sync when you only care about the data being durable. Both return false on failure. If you\u0026rsquo;re writing anything that needs crash safety, you needed these.\nPassing null to non-nullable built-in parameters A quieter but consequential change: built-in functions that accept strings, integers, etc. have always silently swallowed null and coerced it. In 8.1, that starts emitting a deprecation notice.\nstr_contains(\u0026#34;foobar\u0026#34;, null); // Deprecated: Passing null to parameter #2 ($needle) of type string is deprecated This aligns built-in functions with user-defined functions, which already refused nullable arguments for non-nullable parameters. PHP 9.0 turns this into a hard error. If you\u0026rsquo;re passing null into string functions, now is a better time to fix it than during a production incident.\nMySQLi exceptions by default Before 8.1, MySQLi failed silently unless you explicitly called mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT). That\u0026rsquo;s now the default:\n// This throws \\mysqli_sql_exception on connection failure in 8.1 // Previously returned false and set an error you had to check manually $connection = new mysqli(\u0026#39;localhost\u0026#39;, \u0026#39;user\u0026#39;, \u0026#39;wrong_password\u0026#39;, \u0026#39;db\u0026#39;); Every codebase that catches MySQLi errors by checking return values needs to be reviewed. The silent failures that caused hard-to-diagnose bugs now throw exceptions, which is the right behavior, just potentially surprising if you hit it mid-upgrade.\n","permalink":"https://guillaumedelre.github.io/2022/01/09/php-8.1-enums-fibers-and-the-type-system-growing-up/","summary":"\u003cp\u003ePHP 8.1 released November 25th. It follows 8.0\u0026rsquo;s sweeping overhaul with something different: fewer features, but each one thought through rather than bolted on.\u003c/p\u003e\n\u003ch2 id=\"enums\"\u003eEnums\u003c/h2\u003e\n\u003cp\u003eThis is the one that changes codebases the moment you upgrade. Before 8.1, PHP enumerations were either class constants, strings, or integers with nothing enforcing them:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// before: nothing stops Status::INVALID from being passed\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eACTIVE\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;active\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eINACTIVE\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;inactive\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// after\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eenum\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eStatus\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eActive\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;active\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eInactive\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;inactive\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eactivate\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eStatus\u003c/span\u003e $status)\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003evoid\u003c/span\u003e { \u003cspan style=\"color:#f92672\"\u003e...\u003c/span\u003e }\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003ePHP enums are objects, not scalars. They support methods, interfaces, and constants. Backed enums (with a string or int value) serialize cleanly and map to database columns naturally. Pure enums (no backing type) enforce domain concepts without worrying about serialization.\u003c/p\u003e","title":"PHP 8.1: enums, fibers, and the type system growing up"},{"content":"PHP 8.0 shipped November 26th. I\u0026rsquo;ve been running it for six weeks on a side project and a greenfield service at work. It\u0026rsquo;s the most significant PHP release since 7.0, and in some ways more impactful, because the changes pile on top of each other in useful ways.\nJIT The Just-In-Time compiler was the headline announcement. The reality in production is more nuanced: for typical web apps (database queries, HTTP calls, template rendering) the gains are modest, because those workloads are I/O bound, not compute bound. Where JIT actually shines is CPU-intensive code: image manipulation, data transformation, mathematical computation.\nFor most web apps, the performance improvement comes from the overall engine work in 8.0, not JIT specifically. Still worth enabling though: it costs nothing on I/O-bound work.\nMatch expressions switch has three problems: it uses loose comparison, it falls through by default, and it can\u0026rsquo;t be used as an expression. match fixes all three:\n$result = match($status) { \u0026#39;active\u0026#39;, \u0026#39;pending\u0026#39; =\u0026gt; \u0026#39;processing\u0026#39;, \u0026#39;done\u0026#39; =\u0026gt; \u0026#39;finished\u0026#39;, default =\u0026gt; throw new \\UnexpectedValueException($status), }; Strict comparison. No fall-through. Expression that returns a value. Non-exhaustive match throws. After one week with match I stopped writing switch.\nNamed arguments array_slice(array: $users, offset: 0, length: 10, preserve_keys: true); Named arguments let you pass arguments in any order and skip optional ones. The obvious win is readability on functions with multiple boolean flags. The less obvious win: named arguments survive PHP version upgrades even when parameter order changes, because you\u0026rsquo;re naming what you mean.\nAttributes Out with docblock annotations (the @Route, @ORM\\Column style that frameworks have relied on for years), in with first-class PHP syntax:\n#[Route(\u0026#39;/users\u0026#39;, methods: [\u0026#39;GET\u0026#39;])] #[IsGranted(\u0026#39;ROLE_ADMIN\u0026#39;)] public function list(): Response { ... } Attributes are validated by the engine, not parsed from strings. IDE support just works, no plugin magic needed. For Symfony and Doctrine users, this is the real daily win of PHP 8.0.\nConstructor promotion class User { public function __construct( public readonly int $id, public string $name, private ?string $email = null, ) {} } Properties declared and assigned in one line in the constructor signature. The most immediate refactoring win in 8.0: every data class I\u0026rsquo;ve touched since upgrading is half the lines it used to be.\nNullsafe operator $city = $user?-\u0026gt;getAddress()?-\u0026gt;getCity()?-\u0026gt;getName(); null at any point in the chain short-circuits the rest and returns null. The alternative was nested null checks or a chain of early returns. This composes naturally.\nUnion types Named arguments make function signatures more explicit at the call site. Union types make them more honest at the declaration site:\nfunction processInput(int|float|string $value): string|int { if (is_string($value)) { return strlen($value); } return (int) round($value); } The union int|float|string is a literal OR. The engine enforces it on entry and exit. Before 8.0, \u0026ldquo;this parameter accepts int or float\u0026rdquo; lived in a docblock that nothing enforced. There\u0026rsquo;s also null as a type component: ?string is just syntactic sugar for string|null, both are valid.\nOne special case: false. PHP has a bunch of built-in functions that return a typed value on success and false on failure. The 8.0 type system accommodates that: array|false, string|false. It\u0026rsquo;s an honest acknowledgment that the codebase can\u0026rsquo;t be rewritten overnight.\nstatic return type static as a return type was possible informally through docblocks, but 8.0 makes it official. The distinction between self and static matters in inheritance:\nclass Builder { protected array $config = []; public function set(string $key, mixed $value): static { $this-\u0026gt;config[$key] = $value; return $this; } } class SpecialBuilder extends Builder {} $result = (new SpecialBuilder())-\u0026gt;set(\u0026#39;foo\u0026#39;, \u0026#39;bar\u0026#39;); // $result is SpecialBuilder, not Builder With self as the return type, that chain would return Builder, breaking fluent interfaces in subclasses. static makes fluent APIs work correctly across inheritance hierarchies without manual overrides.\nmixed type mixed was a docblock convention for years. 8.0 makes it a real type that shows up in signatures:\nfunction debug(mixed $value): void { var_dump($value); } It accepts everything: null, objects, resources, scalars, arrays. Semantically it\u0026rsquo;s the same as having no type declaration, but it\u0026rsquo;s explicit rather than absent. The difference between \u0026ldquo;this parameter is untyped\u0026rdquo; and \u0026ldquo;this parameter intentionally accepts anything.\u0026rdquo; Worth using when you\u0026rsquo;re writing a general-purpose utility that would be dishonest with a narrower type.\nthrow as expression Before 8.0, throw was a statement. Sounds like a pedantic distinction until you hit the places where you actually want an expression:\n// In a ternary: $value = $input ?? throw new \\InvalidArgumentException(\u0026#39;input required\u0026#39;); // In an arrow function: $getId = fn(User $u) =\u0026gt; $u-\u0026gt;id ?? throw new \\RuntimeException(\u0026#39;no id\u0026#39;); // In a match arm (which is already an expression): $status = match($code) { 200 =\u0026gt; \u0026#39;ok\u0026#39;, 404 =\u0026gt; \u0026#39;not found\u0026#39;, default =\u0026gt; throw new \\UnexpectedValueException(\u0026#34;unknown code: $code\u0026#34;), }; The last one is particularly useful: match without a default will throw UnhandledMatchError automatically, but sometimes you want to control the exception type and message.\ncatch without a variable Small quality-of-life fix. When you catch an exception but don\u0026rsquo;t actually use the object, 8.0 lets you omit the variable:\ntry { $result = $cache-\u0026gt;get($key); } catch (CacheMissException) { $result = $this-\u0026gt;compute($key); } Before 8.0, you had to write catch (CacheMissException $e) and then either use $e or live with the IDE warning about an unused variable. Neither was satisfying.\nString functions that should have existed years ago Three functions that every PHP developer has written manually at least once:\nstr_contains(\u0026#39;hello world\u0026#39;, \u0026#39;world\u0026#39;); // true str_starts_with(\u0026#39;hello world\u0026#39;, \u0026#39;hell\u0026#39;); // true str_ends_with(\u0026#39;hello world\u0026#39;, \u0026#39;world\u0026#39;); // true Before 8.0, the go-to approaches were strpos() !== false, strncmp(), or substr() ===, all of which require stopping to remember the semantics. These new functions are just direct and readable. No regex, no offset arithmetic.\nStable sort PHP\u0026rsquo;s sorting functions weren\u0026rsquo;t stable before 8.0. \u0026ldquo;Not stable\u0026rdquo; means elements that compare as equal could end up in any order relative to each other. In practice this caused subtle bugs in UI code that needed consistent ordering, pagination that shifted between loads, and tests that only passed by luck.\n8.0 guarantees stability across all sorting functions: sort(), usort(), array_multisort(), and the rest. Equal elements keep their original relative position. This is the behavior most people assumed was already there.\nWeakMap 7.4 brought WeakReference for single objects. 8.0 brings WeakMap: a map where both the keys (objects) and their associated data can be garbage collected when no other reference to the key object exists:\nclass RequestCache { private WeakMap $cache; public function __construct() { $this-\u0026gt;cache = new WeakMap(); } public function get(Request $request): Response { return $this-\u0026gt;cache[$request] ??= $this-\u0026gt;compute($request); } } The moment $request is no longer referenced anywhere else, the entry disappears from the map. No manual cleanup needed. It\u0026rsquo;s the right pattern for memoization and computed property caches where you don\u0026rsquo;t want to be the sole reason an object stays alive.\nNew exception types ValueError is thrown when a function gets the right type but an invalid value, as opposed to TypeError which fires on wrong types:\narray_chunk([], -5); // ValueError: array_chunk(): Argument #2 ($length) must be greater than 0 Before 8.0, many of these were warnings that returned false or null. Now they throw. The engine is stricter, which means you catch problems earlier instead of getting weird results somewhere downstream.\nget_debug_type() and fdiv() Two utility functions worth knowing.\nget_debug_type() returns a normalized string representation of any value, handy for error messages:\nget_debug_type(1); // \u0026#34;int\u0026#34; get_debug_type(1.0); // \u0026#34;float\u0026#34; get_debug_type(null); // \u0026#34;null\u0026#34; get_debug_type(new Foo()); // \u0026#34;Foo\u0026#34; (not \u0026#34;object\u0026#34;) get_debug_type([]); // \u0026#34;array\u0026#34; The difference from gettype(): it returns class names for objects and uses normalized names (\u0026quot;int\u0026quot; not \u0026quot;integer\u0026quot;). Exactly what you want when building an exception message that says what you got versus what you expected.\nfdiv() performs floating-point division following IEEE 754, meaning division by zero returns INF, -INF, or NAN instead of a warning:\nfdiv(10, 0); // INF fdiv(-10, 0); // -INF fdiv(0, 0); // NAN The changes that break things 8.0 also ships a few changes that aren\u0026rsquo;t features, they\u0026rsquo;re corrections.\nThe big one: 0 == \u0026quot;foo\u0026quot; is now false. In PHP 7, comparing an integer to a non-numeric string would cast the string to 0, so 0 == \u0026quot;anything-non-numeric\u0026quot; evaluated to true. That was a persistent source of bugs and security headaches. PHP 8 flips it: the integer gets cast to a string instead:\nvar_dump(0 == \u0026#34;foo\u0026#34;); // bool(false) in 8.0, bool(true) in 7.x var_dump(0 == \u0026#34;\u0026#34;); // bool(false) in 8.0, bool(true) in 7.x var_dump(0 == \u0026#34;0\u0026#34;); // bool(true) in both (\u0026#34;0\u0026#34; is numeric) If you relied on this intentionally, you already knew it was sketchy. If you didn\u0026rsquo;t know you relied on it, 8.0 will find those code paths for you.\nSeveral functions that used to return resources now return proper objects: curl_init() returns a CurlHandle, imagecreate() returns a GdImage, xml_parser_create() returns an XMLParser. Code that checks is_resource($curl) will break, because is_resource() returns false for these objects. The fix is to check against false (the return value on failure) rather than checking the type of the success case.\nPHP 8.0 is the kind of release where the features reinforce each other. Attributes play well with constructor promotion. Match pairs naturally with union types. The string functions cut noise that was hiding intent. The corrections are occasionally breaking, but they push the language toward consistency it should have had years ago.\n","permalink":"https://guillaumedelre.github.io/2021/01/10/php-8.0-match-named-arguments-attributes-and-jit/","summary":"\u003cp\u003ePHP 8.0 shipped November 26th. I\u0026rsquo;ve been running it for six weeks on a side project and a greenfield service at work. It\u0026rsquo;s the most significant PHP release since 7.0, and in some ways more impactful, because the changes pile on top of each other in useful ways.\u003c/p\u003e\n\u003ch2 id=\"jit\"\u003eJIT\u003c/h2\u003e\n\u003cp\u003eThe Just-In-Time compiler was the headline announcement. The reality in production is more nuanced: for typical web apps (database queries, HTTP calls, template rendering) the gains are modest, because those workloads are I/O bound, not compute bound. Where JIT actually shines is CPU-intensive code: image manipulation, data transformation, mathematical computation.\u003c/p\u003e","title":"PHP 8.0: match, named arguments, attributes, and JIT"},{"content":"Every content update on the platform creates a revision. That\u0026rsquo;s by design: editors need a history they can roll back to, and the platform needs an audit trail. What nobody anticipated was the rate. Some articles go through forty saves in a single afternoon. A high-traffic piece accumulates hundreds of revisions over its lifetime. After a few months, the revision table had several million rows.\nDeleting them naively wasn\u0026rsquo;t an option. \u0026ldquo;Keep the last 50\u0026rdquo; loses all historical context for articles that haven\u0026rsquo;t been touched in a year. \u0026ldquo;Keep one per day\u0026rdquo; loses all the detail for content that\u0026rsquo;s actively being edited. What we needed was a distribution that matched how revisions are actually used: dense coverage for recent history, sparse coverage for old history.\nThat\u0026rsquo;s a logarithmic distribution. And building it required raw SQL.\nWhy simple strategies fail The appeal of a fixed window is obvious: keep the N most recent revisions and delete the rest. It\u0026rsquo;s one line of SQL and zero math. The problem is that it treats a revision from yesterday and a revision from three years ago as equally valuable, which they aren\u0026rsquo;t. An editor who opens an article from 2017 doesn\u0026rsquo;t need its last 50 versions; they might need one per quarter. An article that shipped this morning might need every save from the past hour.\nA time-based strategy (one revision per calendar day) has the opposite problem: it\u0026rsquo;s too aggressive for active content. If an article gets 30 saves between 09:00 and 10:00, all of them except one disappear. That\u0026rsquo;s not history, that\u0026rsquo;s erasure.\nNeither strategy can express \u0026ldquo;keep more detail for recent content, less for old content.\u0026rdquo; That relationship is logarithmic.\nThe scoring idea The algorithm assigns each revision a score based on its age, then keeps only one revision per score bucket. The score formula produces high, widely-spaced values for recent revisions and small, clustered values for old ones.\nThe core expression, simplified, looks like this:\n( ln( EXTRACT(epoch FROM (now() - created_at)) ) / ( EXTRACT(epoch FROM (now() - created_at)) / 6000 ) ) * ( 1 / (EXTRACT(epoch FROM (now() - created_at)) / 60 / 1440) ) * 1000 Let s be the age in seconds. The formula is roughly ln(s) / s * C, where both the logarithm in the numerator and s in the denominator make the result decrease rapidly as s grows.\nCast to an integer, the effect is this: a revision saved 10 minutes ago might score 8432, one saved 11 minutes ago scores 8431. They\u0026rsquo;re in different buckets. A revision from six months ago scores 2, one from eight months ago also scores 2. Same bucket. The window function then picks the most recent revision from each bucket and discards the rest.\nThe result is automatic: recent saves are all kept because each has a distinct score; old saves are thinned because many share the same score.\nThe DQL attempt that didn\u0026rsquo;t ship Window functions aren\u0026rsquo;t part of DQL. Doctrine\u0026rsquo;s query language has no syntax for OVER, PARTITION BY, or ROW_NUMBER(). Before going to raw SQL, the team tried to add them.\nThe FunctionNode approach works for single SQL functions, as we\u0026rsquo;d already seen with FTS. A RowNumber node emitting ROW_NUMBER() is trivial:\nclass RowNumber extends FunctionNode { public function getSql(SqlWalker $sqlWalker): string { return \u0026#39;ROW_NUMBER()\u0026#39;; } } The harder part is OVER(PARTITION BY ... ORDER BY ...). An Over function node was drafted, with a custom PartitionByClause AST node to handle the PARTITION BY clause:\nclass Over extends FunctionNode { protected ?PartitionByClause $partitionByClause = null; protected ?OrderByClause $orderByClause = null; public function getSql(SqlWalker $sqlWalker): string { return \u0026#39;OVER(\u0026#39; .($this-\u0026gt;partitionByClause ? $this-\u0026gt;partitionByClause-\u0026gt;dispatch($sqlWalker) : ($this-\u0026gt;orderByClause ? $this-\u0026gt;orderByClause-\u0026gt;dispatch($sqlWalker) : \u0026#39;\u0026#39;)) .\u0026#39;)\u0026#39;; } } It was never finished. The classes shipped marked @deprecated and \u0026ldquo;NOT TESTED YET\u0026rdquo;. The issue is composability: DQL\u0026rsquo;s FunctionNode works cleanly for functions that appear in WHERE clauses or SELECT expressions. A window function like ROW_NUMBER() OVER (PARTITION BY ...) is a different structure: it appears in a SELECT position, modifies the surrounding query semantics, and requires the parser to handle PARTITION BY as an extension to DQL\u0026rsquo;s grammar. Making that robust enough to trust in production is a significant investment. Going to DBAL and writing the SQL directly took an afternoon.\nThe query, layer by layer The final implementation is three nested queries:\nDELETE FROM revision WHERE iri = ? AND id NOT IN ( SELECT id FROM ( SELECT row_number() OVER ( PARTITION BY num, iri ORDER BY num DESC, created_at DESC ) AS lines, * FROM ( SELECT ( ( ln( EXTRACT(epoch FROM (now() - created_at)) ) / ( EXTRACT(epoch FROM (now() - created_at)) / 6000 ) ) * ( 1 / (EXTRACT(epoch FROM (now() - created_at)) / 60 / 1440) ) * 1000 )::numeric::integer AS num, * FROM revision WHERE iri = ? ORDER BY created_at DESC ) AS lst ) AS rst WHERE lines = 1 ); Inner query: computes num, the integer score, for every revision of the given IRI. Rows are sorted by created_at DESC at this stage.\nMiddle query: runs ROW_NUMBER() OVER (PARTITION BY num, iri ORDER BY num DESC, created_at DESC). Within each score bucket (num), revisions are numbered starting from 1 in descending age order. The most recent revision in each bucket gets lines = 1.\nOuter filter: keeps only the lines = 1 rows, one revision per score bucket.\nDELETE: removes every revision for this IRI that isn\u0026rsquo;t in the kept set.\nThe PARTITION BY num, iri is redundant on the IRI (the whole query is already filtered to one IRI), but makes the intent explicit and keeps the logic correct if the query is ever reused in a broader context.\nThe method is called from a companion query that identifies which IRIs have accumulated more than a threshold of revisions:\npublic function getIrisWithMoreRevisionThan(int $maxRevisionsCount, int $limit = 0, ?int $retencyDay = null): array { $queryBuilder = $this -\u0026gt;createQueryBuilder(\u0026#39;revision\u0026#39;) -\u0026gt;select(\u0026#39;revision.iri\u0026#39;) -\u0026gt;groupBy(\u0026#39;revision.iri\u0026#39;) -\u0026gt;having(\u0026#39;COUNT(1) \u0026gt; :maxRevisions\u0026#39;) -\u0026gt;orderBy(\u0026#39;COUNT(1)\u0026#39;, Order::Descending-\u0026gt;value) -\u0026gt;setParameter(\u0026#39;maxRevisions\u0026#39;, $maxRevisionsCount); // ... return array_column($queryBuilder-\u0026gt;getQuery()-\u0026gt;getResult(), \u0026#39;iri\u0026#39;); } The two methods run together in a scheduled cleanup: find the IRIs over the threshold, prune each one.\nWiring it to a scheduled command The pruning query doesn\u0026rsquo;t run in a request. It runs behind a Symfony command, called on a schedule.\nThe command takes a few options to control how aggressively it runs:\n#[AsCommand(\u0026#39;app:purge:revision\u0026#39;, \u0026#39;Remove useless revisions\u0026#39;)] final class PurgeRevisionCommand extends Command { protected function configure(): void { $this -\u0026gt;addOption(\u0026#39;max-revisions\u0026#39;, \u0026#39;m\u0026#39;, InputOption::VALUE_REQUIRED, \u0026#39;Revision threshold above which an IRI gets pruned\u0026#39;, 30) -\u0026gt;addOption(\u0026#39;limit\u0026#39;, \u0026#39;l\u0026#39;, InputOption::VALUE_REQUIRED, \u0026#39;Max number of IRIs to process per run\u0026#39;) -\u0026gt;addOption(\u0026#39;delay\u0026#39;, \u0026#39;w\u0026#39;, InputOption::VALUE_REQUIRED, \u0026#39;Delay in seconds between each IRI\u0026#39;) -\u0026gt;addOption(\u0026#39;retencyDay\u0026#39;, \u0026#39;r\u0026#39;, InputOption::VALUE_OPTIONAL, \u0026#39;Only process IRIs whose last revision is older than N days\u0026#39;); } protected function execute(InputInterface $input, OutputInterface $output): int { $iris = $this-\u0026gt;revisionRepository-\u0026gt;getIrisWithMoreRevisionThan( (int) $input-\u0026gt;getOption(\u0026#39;max-revisions\u0026#39;), (int) $input-\u0026gt;getOption(\u0026#39;limit\u0026#39;), (int) $input-\u0026gt;getOption(\u0026#39;retencyDay\u0026#39;), ); foreach ($iris as $iri) { $totalDeleted += $this-\u0026gt;revisionRepository-\u0026gt;deleteOldRevisionForIri($iri); usleep((int) $input-\u0026gt;getOption(\u0026#39;delay\u0026#39;) * 1_000_000); } return Command::SUCCESS; } } The --delay option is worth noting: on a busy database, hammering a hundred DELETE statements back-to-back can cause lock contention. A small sleep between iterations keeps the purge from competing with production traffic.\nThe command runs behind two crontab entries with different thresholds:\n# Hourly: keep 30 revisions per IRI, process 100 IRIs per run 0 * * * * php bin/console app:purge:revision --max-revisions 30 --limit 100 # Nightly: for content untouched for a year, keep only 3 0 0 * * * php bin/console app:purge:revision --max-revisions 3 --limit 100 --retencyDay 365 The two-level strategy matters. The hourly job keeps 30 revisions per IRI, which is a reasonable ceiling for actively-edited content. The nightly job targets only IRIs not updated in over a year and keeps just 3. An article that hasn\u0026rsquo;t moved in twelve months doesn\u0026rsquo;t need thirty versions in its history.\nWhat it looks like in practice An article saved 200 times will typically keep 20 to 30 revisions after pruning: most of the recent saves, a handful from last month, one or two from each quarter of the previous year. The exact count depends on the age distribution of the saves, not on an arbitrary cap.\nAn article last edited two years ago might end up with 5 or 6 revisions. Recent edits are all there; the old history is compressed but not gone.\nIt\u0026rsquo;s not a perfect history. It\u0026rsquo;s a useful one.\nThe line between DQL and raw SQL The window function attempt isn\u0026rsquo;t a failure worth hiding. It\u0026rsquo;s a useful data point: FunctionNode works well for scalar functions in WHERE and SELECT positions, but composing a full ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) expression in DQL is harder than it looks. The grammar extension, the AST nodes, the SQL walker integration: it\u0026rsquo;s a non-trivial amount of code for something that native SQL handles in three lines.\nThe practical boundary is roughly this: if a PostgreSQL feature maps to a function call with fixed arity, custom DQL works. If it requires new clause syntax (window frames, CTEs, lateral joins), native DBAL is usually the better trade-off.\n","permalink":"https://guillaumedelre.github.io/2020/09/27/revision-pruning-with-window-functions-and-logarithms-when-dql-wasnt-enough/","summary":"\u003cp\u003eEvery content update on the platform creates a revision. That\u0026rsquo;s by design: editors need a history they can roll back to, and the platform needs an audit trail. What nobody anticipated was the rate. Some articles go through forty saves in a single afternoon. A high-traffic piece accumulates hundreds of revisions over its lifetime. After a few months, the revision table had several million rows.\u003c/p\u003e\n\u003cp\u003eDeleting them naively wasn\u0026rsquo;t an option. \u0026ldquo;Keep the last 50\u0026rdquo; loses all historical context for articles that haven\u0026rsquo;t been touched in a year. \u0026ldquo;Keep one per day\u0026rdquo; loses all the detail for content that\u0026rsquo;s actively being edited. What we needed was a distribution that matched how revisions are actually used: dense coverage for recent history, sparse coverage for old history.\u003c/p\u003e","title":"Revision pruning with window functions and logarithms, when DQL wasn't enough"},{"content":"PHP 7.4 landed November 28th. It\u0026rsquo;s the last 7.x release before PHP 8.0, and it feels like it. The features are substantial enough to stand on their own, but they also read as groundwork for what\u0026rsquo;s coming.\nTyped properties This is the one. Since PHP 7.0, you could type function parameters and return values. But class properties? Still untyped:\nclass User { public int $id; public string $name; public ?DateTimeInterface $deletedAt; } 7.4 changes that. Typed properties enforce types at assignment, not just at call sites. Classes become self-documenting in a way that docblocks never quite managed, and the engine catches type errors before they propagate through half your stack.\nOne subtlety: typed properties are uninitialized by default (not null). Accessing an uninitialized property throws an Error. This trips people up: ?string doesn\u0026rsquo;t imply a default of null. You still need an explicit = null for that.\nArrow functions Closures in PHP have always required explicitly importing outer scope variables with use:\n$multiplier = 3; $fn = fn($x) =\u0026gt; $x * $multiplier; // no use() needed Arrow functions capture the enclosing scope automatically. Single expression, implicit return, no boilerplate. They don\u0026rsquo;t replace full closures for complex logic, but for short callbacks they eliminate a class of noise that had been accumulating for years.\nOpcache preloading For long-lived PHP-FPM setups, preloading allows a script to load and compile PHP files into opcache memory at server startup. Those files are available to all requests without compilation overhead.\nThe gain varies by application. On large frameworks where the same files are loaded on every request, it\u0026rsquo;s real. On smaller apps, negligible. Worth benchmarking before adding the configuration complexity.\nThe small ones that add up The features mentioned in passing deserve more than a line. The null coalescing assignment operator ??= solves a pattern that was annoying enough to write every single time but never annoying enough to bother abstracting:\n$config[\u0026#39;timeout\u0026#39;] ??= 30; // equivalent to: $config[\u0026#39;timeout\u0026#39;] = $config[\u0026#39;timeout\u0026#39;] ?? 30; Spread operator in array literals does what you\u0026rsquo;d expect from the function call version — unpack an iterable into an array literal:\n$defaults = [\u0026#39;color\u0026#39; =\u0026gt; \u0026#39;blue\u0026#39;, \u0026#39;size\u0026#39; =\u0026gt; \u0026#39;M\u0026#39;]; $options = [\u0026#39;size\u0026#39; =\u0026gt; \u0026#39;L\u0026#39;, ...$defaults, \u0026#39;weight\u0026#39; =\u0026gt; 1.2]; // [\u0026#39;size\u0026#39; =\u0026gt; \u0026#39;M\u0026#39;, \u0026#39;color\u0026#39; =\u0026gt; \u0026#39;blue\u0026#39;, \u0026#39;weight\u0026#39; =\u0026gt; 1.2] Note: string keys weren\u0026rsquo;t supported in 7.4 for array unpacking. That came later.\nCovariant return types and contravariant parameter types close a gap that made some inheritance patterns needlessly awkward. A child class can now narrow its return type to a subtype of the parent\u0026rsquo;s, without hitting a fatal error:\nclass Producer { public function get(): Iterator {} } class ChildProducer extends Producer { public function get(): ArrayIterator {} // ArrayIterator implements Iterator } Reading numbers at 3am The numeric literal separator is one of those features you don\u0026rsquo;t know you wanted until the first time you write a large constant and immediately lose track of the magnitude:\n$earthMass = 5_972_168_000_000_000_000_000_000; // kg $lightSpeed = 299_792_458; // m/s $planck = 6.626_070_15e-34; // J·s $hexMask = 0xFF_EC_D5_08; $binaryFlags = 0b0001_1111_0010_0000; The underscore is purely syntactic. The engine strips it before parsing the value. You can put it anywhere between digits, though convention follows the natural grouping of the number system you\u0026rsquo;re working in.\nHolding without owning WeakReference lets you hold a reference to an object without preventing the garbage collector from destroying it. The use case is caches and registries: you want to know an object is alive, but you don\u0026rsquo;t want to be the reason it stays alive:\n$object = new HeavyObject(); $ref = WeakReference::create($object); var_dump($ref-\u0026gt;get()); // object(HeavyObject) unset($object); var_dump($ref-\u0026gt;get()); // NULL — GC collected it Before 7.4 you had WeakRef via an extension, and some frameworks were doing SplObjectStorage tricks that didn\u0026rsquo;t quite behave the same way. The native class is just straightforward.\nSerialization without surprise Custom object serialization before 7.4 went through the Serializable interface: implement serialize() and unserialize(), return a string, reconstruct from it. The problem is that serialize() triggered __sleep(), unserialize() triggered __wakeup(), and the interaction between these hooks was fragile, especially in inheritance hierarchies.\n7.4 introduces __serialize() and __unserialize(), which work with arrays instead of strings and don\u0026rsquo;t interact with the old hooks:\nclass Session { private string $token; private \\DateTime $createdAt; public function __serialize(): array { return [\u0026#39;token\u0026#39; =\u0026gt; $this-\u0026gt;token, \u0026#39;created\u0026#39; =\u0026gt; $this-\u0026gt;createdAt-\u0026gt;getTimestamp()]; } public function __unserialize(array $data): void { $this-\u0026gt;token = $data[\u0026#39;token\u0026#39;]; $this-\u0026gt;createdAt = (new \\DateTime())-\u0026gt;setTimestamp($data[\u0026#39;created\u0026#39;]); } } When both the new and old methods exist on the same class, __serialize() wins. The old Serializable interface gets deprecated in 8.1.\nWhat the standard library quietly got mb_str_split() does what str_split() does but correctly for multibyte strings. The gap was genuinely embarrassing for a language used in as many locales as PHP:\nmb_str_split(\u0026#39;héllo\u0026#39;, 1); // [\u0026#39;h\u0026#39;, \u0026#39;é\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;] str_split(\u0026#39;héllo\u0026#39;, 1); // [\u0026#39;h\u0026#39;, \u0026#39;Ã\u0026#39;, \u0026#39;©\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;] — broken strip_tags() now accepts an array of allowed tags, which is cleaner than the string format it used to require:\nstrip_tags($html, [\u0026#39;p\u0026#39;, \u0026#39;br\u0026#39;, \u0026#39;strong\u0026#39;]); // was: \u0026#39;\u0026lt;p\u0026gt;\u0026lt;br\u0026gt;\u0026lt;strong\u0026gt;\u0026#39; proc_open() now accepts an array command, bypassing shell interpretation entirely. Same idea as Python\u0026rsquo;s subprocess with shell=False. Worth knowing whenever you\u0026rsquo;re passing user-supplied arguments to an external process.\nThe FFI chapter The Foreign Function Interface extension landed in 7.4 after spending time in a feature branch. It lets PHP call native C functions by loading a shared library and declaring the signatures:\n$ffi = FFI::cdef(\u0026#34;int strlen(const char *s);\u0026#34;, \u0026#34;libc.so.6\u0026#34;); var_dump($ffi-\u0026gt;strlen(\u0026#34;hello\u0026#34;)); // int(5) The practical applications are narrow but real: calling platform APIs with no PHP binding, wrapping performance-critical C code without writing a full extension, or just poking at native libraries directly. Not a replacement for proper extensions in production, but it removes the \u0026ldquo;write a C extension\u0026rdquo; barrier for exploration.\nWhat got deprecated A few things that should have been cleaned up a while ago finally got the deprecation treatment in 7.4.\nNested ternaries without parentheses have been ambiguous forever. PHP evaluated them left-to-right while pretty much every other language with a ternary evaluates right-to-left:\n// Was ambiguous, now deprecated: $a ? $b : $c ? $d : $e; // Make it explicit: ($a ? $b : $c) ? $d : $e; Curly brace offset access for strings and arrays — $str{0} instead of $str[0] — is deprecated and gone in 8.0. It was always an alias, never a separate feature.\nimplode() with reversed argument order (array first, glue second) is deprecated. The function has accepted both orders since the beginning, which was a mistake. The correct order is implode(string $separator, array $array).\nWhat comes next 7.4 is the last 7.x release. The deprecations are mostly ground-clearing for removals in 8.0. The RFC backlog for 8.0 is substantial: JIT, attributes, named arguments, match expressions. 7.4 is a solid place to land while waiting for all that to arrive.\n","permalink":"https://guillaumedelre.github.io/2020/01/12/php-7.4-typed-properties-and-the-arrow-function-you-actually-want/","summary":"\u003cp\u003ePHP 7.4 landed November 28th. It\u0026rsquo;s the last 7.x release before PHP 8.0, and it feels like it. The features are substantial enough to stand on their own, but they also read as groundwork for what\u0026rsquo;s coming.\u003c/p\u003e\n\u003ch2 id=\"typed-properties\"\u003eTyped properties\u003c/h2\u003e\n\u003cp\u003eThis is the one. Since PHP 7.0, you could type function parameters and return values. But class properties? Still untyped:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eint\u003c/span\u003e $id;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estring\u003c/span\u003e $name;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eDateTimeInterface\u003c/span\u003e $deletedAt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e7.4 changes that. Typed properties enforce types at assignment, not just at call sites. Classes become self-documenting in a way that docblocks never quite managed, and the engine catches type errors before they propagate through half your stack.\u003c/p\u003e","title":"PHP 7.4: typed properties and the arrow function you actually want"},{"content":"Symfony 5.0 released November 21, 2019, same day as 4.4. Where 4.4 is about stability and a long support window, 5.0 is the next chapter: no deprecated code, PHP 7.2.5 minimum, and a handful of new components that finally address gaps that had piled up for years.\nThe String component PHP\u0026rsquo;s string handling is famously scattered: prefix-style functions here (str_), suffix-style there (strpos), inconsistent encoding support, and nothing object-oriented in sight. The String component wraps all of this into a fluent, unicode-aware object API:\nuse Symfony\\Component\\String\\UnicodeString; $str = new UnicodeString(\u0026#39; Hello World \u0026#39;); echo $str-\u0026gt;trim()-\u0026gt;lower()-\u0026gt;replace(\u0026#39; \u0026#39;, \u0026#39;-\u0026#39;); // hello-world The practical addition is the Slugger, a locale-aware slug generator that actually handles accented characters correctly:\n$slug = $slugger-\u0026gt;slug(\u0026#39;L\\\u0026#39;été à Montréal\u0026#39;); // l-ete-a-montreal Before, you\u0026rsquo;d pull in a third-party library or write your own. Now it ships with FrameworkBundle, available by default.\nNotifier Email is handled by Mailer. SMS, push notifications, chat messages: no first-party story, until now. The Notifier component adds one: a unified interface over dozens of channels and providers.\nThe same notification can hit Slack, trigger an SMS via Twilio, or end up as a push notification, all configured through DSNs. Adding a new channel is a config change, not a code change.\nSecrets vault Storing secrets in .env files works, but the values are plain text, shared environments are a pain, and there\u0026rsquo;s no native way to encrypt anything at rest.\nSymfony 5.0 adds a secrets: command family and a vault mechanism. Secrets are encrypted with a key pair stored outside the repository. The encrypted files get committed; the decrypt key does not. In production, the key comes in as an environment variable or gets injected from a secret manager.\nphp bin/console secrets:set DATABASE_PASSWORD php bin/console secrets:decrypt-to-local --force Not a full-blown secrets management solution, but a real step up from a plain .env file sitting unencrypted in your repo.\nMailer gets a notification layer The Mailer component arrived in 4.4. What 5.0 adds on top is the NotificationEmail — a pre-styled, responsive email built on Foundation for Emails, with an explicit API for importance levels and call-to-action buttons:\nuse Symfony\\Bridge\\Twig\\Mime\\NotificationEmail; $email = (new NotificationEmail()) -\u0026gt;from(\u0026#39;alerts@example.com\u0026#39;) -\u0026gt;to(\u0026#39;admin@example.com\u0026#39;) -\u0026gt;subject(\u0026#39;Disk usage critical\u0026#39;) -\u0026gt;markdown(\u0026#39;The disk on **prod-01** is at 94%. Check it now.\u0026#39;) -\u0026gt;action(\u0026#39;Open dashboard\u0026#39;, \u0026#39;https://example.com/servers\u0026#39;) -\u0026gt;importance(NotificationEmail::IMPORTANCE_URGENT); No template to write, no inline CSS to wrestle with. For transactional alerts, billing notifications, and system emails, it covers 80% of what you need without touching anything.\nLazy firewalls and the caching problem Every stateful firewall in Symfony loads the user from session on every request, whether the action needs it or not. Which means any response is uncacheable by default, even for pages that never touch $this-\u0026gt;getUser().\n5.0 adds lazy mode for firewalls, which defers session access until the code actually calls is_granted() or reaches for the user token:\n# config/packages/security.yaml security: firewalls: main: pattern: ^/ anonymous: lazy Pages that don\u0026rsquo;t need the user become cacheable again. New projects get this by default via the Flex recipe; existing ones need a one-line config change.\nPassword migrations without the big bang Migrating a live app from bcrypt to argon2id used to mean forcing a password reset on every user. The PasswordUpgraderInterface makes it gradual: at login, Symfony checks whether the stored hash matches the current algorithm. If not, it rehashes on the spot and calls your upgrader to save it:\n// src/Repository/UserRepository.php class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface { public function upgradePassword(UserInterface $user, string $newHashedPassword): void { $user-\u0026gt;setPassword($newHashedPassword); $this-\u0026gt;getEntityManager()-\u0026gt;flush(); } } Pair that with algorithm: auto in the encoder config, and old hashes migrate silently as users log in. No migration script, no downtime, no user friction.\nErrorHandler replaces Debug The Debug component is gone. Its replacement, ErrorHandler, does the same job (converting PHP errors to exceptions, showing nice error pages) but without requiring Twig. For API apps that never render HTML, that matters: ErrorHandler generates errors in the format of the request (JSON, XML, plain text) following RFC 7807:\n{ \u0026#34;title\u0026#34;: \u0026#34;Not Found\u0026#34;, \u0026#34;status\u0026#34;: 404, \u0026#34;detail\u0026#34;: \u0026#34;Sorry, the page you are looking for could not be found\u0026#34; } The routing config moves from TwigBundle to FrameworkBundle, and that\u0026rsquo;s the only migration step for most projects. One line, done.\nEvent listeners, finally less verbose Registering a kernel event listener used to mean explicitly naming the event in the service tag. Symfony 5.0 infers it from the method signature:\n// No tag configuration needed beyond kernel.event_listener final class SecurityListener { public function onKernelRequest(RequestEvent $event): void { // Symfony reads the type hint and figures out the event } } # config/services.yaml App\\EventListener\\SecurityListener: tags: [kernel.event_listener] Use __invoke() and it works the same way. Bulk-register a whole directory of listeners with one resource block, and Symfony figures out which event each one handles.\nHttpClient grows up The HttpClient component arrived in 4.4 as stable. 5.0 adds a few useful things on top:\nNTLM authentication for corporate environments, conditional buffering via a callback (buffer large responses only when the content-type matches), a max_duration option that caps the total request time regardless of network conditions, and toStream() to turn any response into a standard PHP stream for code that expects fread():\n$response = $client-\u0026gt;request(\u0026#39;GET\u0026#39;, \u0026#39;https://api.example.com/large-export\u0026#39;, [ \u0026#39;max_duration\u0026#39; =\u0026gt; 30.0, \u0026#39;buffer\u0026#39; =\u0026gt; fn(array $headers): bool =\u0026gt; str_contains($headers[\u0026#39;content-type\u0026#39;][0] ?? \u0026#39;\u0026#39;, \u0026#39;json\u0026#39;), ]); // Stream it instead of loading it all into memory $stream = $response-\u0026gt;toStream(); The client also got full interoperability with PSR-18 and HTTPlug v1/v2, so any library that depends on those abstractions just works with it.\nWhat 5.0 removes 5.0 drops everything deprecated in 4.4. The most notable:\nWebServerBundle (use symfony server:start from the CLI tool instead) The old security system\u0026rsquo;s AnonymousToken (replaced by NullToken) Old form event names Symfony\u0026rsquo;s internal ClassLoader The Debug component (replaced by ErrorHandler) If you ran your 4.4 app with deprecation notices active and fixed the warnings, upgrading to 5.0 requires no code changes.\n","permalink":"https://guillaumedelre.github.io/2020/01/06/symfony-5.0-string-notifier-and-the-secrets-vault/","summary":"\u003cp\u003eSymfony 5.0 released November 21, 2019, same day as 4.4. Where 4.4 is about stability and a long support window, 5.0 is the next chapter: no deprecated code, PHP 7.2.5 minimum, and a handful of new components that finally address gaps that had piled up for years.\u003c/p\u003e\n\u003ch2 id=\"the-string-component\"\u003eThe String component\u003c/h2\u003e\n\u003cp\u003ePHP\u0026rsquo;s string handling is famously scattered: prefix-style functions here (\u003ccode\u003estr_\u003c/code\u003e), suffix-style there (\u003ccode\u003estrpos\u003c/code\u003e), inconsistent encoding support, and nothing object-oriented in sight. The String component wraps all of this into a fluent, unicode-aware object API:\u003c/p\u003e","title":"Symfony 5.0: String, Notifier, and the secrets vault"},{"content":"Symfony 4.4 and 5.0 both landed November 21, 2019. 4.4 is the LTS: same feature set as 5.0, deprecation layer baked in, and a long support window for teams that can\u0026rsquo;t follow every release.\nThe feature worth singling out arrived in 4.2 and matured through 4.3 and 4.4: HttpClient.\nHttpClient PHP\u0026rsquo;s built-in HTTP options (file_get_contents with stream contexts, cURL, Guzzle) each have their own model, their own quirks, and their own abstraction cost. Symfony 4.2 introduced HttpClient, a first-party HTTP client with one API over multiple transports.\nThe interface is clean:\n$response = $client-\u0026gt;request(\u0026#39;GET\u0026#39;, \u0026#39;https://api.example.com/users\u0026#39;); $users = $response-\u0026gt;toArray(); The implementation is async by default. Responses are lazy: the network request doesn\u0026rsquo;t happen until you actually read the response. Multiple requests can be initiated and resolved as data arrives, no threads or callbacks needed.\nThe built-in mock transport (MockHttpClient) makes testing HTTP calls painless without spinning up servers or patching global functions.\nMailer Also stabilized in 4.4: the Mailer component, replacing SwiftMailerBundle as the recommended email solution. Transport is configured via DSN:\nMAILER_DSN=smtp://user:pass@smtp.example.com:587 The DSN approach means switching providers (Mailgun, Postmark, SES, local SMTP) is a config change, not a code change. Email testing uses a spooler by default in non-production environments.\nMessenger matures The Messenger component landed in 3.4 as experimental. By 4.4 it\u0026rsquo;s stable and battle-tested: async message handling with retry logic, failure transport, and adapters for AMQP, Redis, Doctrine, and in-process transports.\nThe pattern it enables (handle a request synchronously, dispatch work asynchronously, retry on failure) replaces a class of Gearman/RabbitMQ setups that required separate libraries and significant configuration.\nThe LTS window 4.4 is supported for bugs until November 2022 and security fixes until November 2023. If you\u0026rsquo;re on 4.x and want stability, this is a comfortable place to land. The deprecation warnings it introduces point directly at what 5.0 will require.\nThe Messenger component, from experimental to production Messenger arrived in 4.1 as an experiment. The concept was simple: dispatch a message object to a bus, handle it immediately or route it to a transport for async processing. By 4.3 and 4.4, the experiment had become infrastructure.\nThe 4.3 release added a dedicated failure transport. When a message fails after all retry attempts, it goes somewhere recoverable rather than just disappearing:\nframework: messenger: failure_transport: failed transports: async: \u0026#39;%env(MESSENGER_TRANSPORT_DSN)%\u0026#39; failed: \u0026#39;doctrine://default?queue_name=failed\u0026#39; routing: App\\Message\\SendEmail: async Messages that land in failed can be inspected and retried manually. Before this, failed messages were a log entry and a headache. After this, they\u0026rsquo;re a queue you can actually work with.\nEvent dispatching, finally using objects properly Since the beginning, Symfony\u0026rsquo;s event system used string event names as the primary identifier. You\u0026rsquo;d define OrderEvents::NEW_ORDER = 'order.new_order', listen on that string, and pass the event object as a secondary parameter.\n4.3 flipped this around. The event object comes first, and the event name becomes optional:\n// Before $dispatcher-\u0026gt;dispatch(OrderEvents::NEW_ORDER, $event); // 4.3+ $dispatcher-\u0026gt;dispatch($event); Omit the name and Symfony uses the class name as the identifier. Listeners and subscribers can now reference the class directly:\npublic static function getSubscribedEvents(): array { return [ OrderPlacedEvent::class =\u0026gt; \u0026#39;onOrderPlaced\u0026#39;, ]; } The HttpKernel events were renamed accordingly: GetResponseEvent became RequestEvent, FilterResponseEvent became ResponseEvent. The old names stayed as aliases through 4.x.\nVarDumper gets a server dump() in a controller that returns JSON means your debug output gets injected straight into the response body. For API development, that\u0026rsquo;s annoying enough to make people disable dumping entirely.\n4.1 added a VarDumper server that captures dumps separately:\nbin/console server:dump Configure the dump destination in config/packages/dev/debug.yaml:\ndebug: dump_destination: \u0026#34;tcp://%env(VAR_DUMPER_SERVER)%\u0026#34; Now dump() in your API controller sends data to the server\u0026rsquo;s console instead of polluting the response. The server shows the dump alongside its source file, the HTTP request that triggered it, and the timestamp.\nVarExporter, for when var_export() fails you var_export() has two problems: it ignores serialization semantics and its output isn\u0026rsquo;t PSR-2 compliant. The 4.2 VarExporter component fixes both.\n$exported = VarExporter::export([123, [\u0026#39;abc\u0026#39;, true]]); // Returns: // [ // 123, // [ // \u0026#39;abc\u0026#39;, // true, // ], // ] More importantly, it correctly handles objects implementing Serializable, __sleep, and __wakeup. Where var_export() silently drops serialization methods and exports raw properties, VarExporter produces code that calls the same hooks unserialize() would. The practical use case is cache warming: generating PHP files that can be loaded by OPcache without re-executing expensive computations.\nPasswords that check against breach databases The NotCompromisedPassword constraint arrived in 4.3. It checks submitted passwords against haveibeenpwned.com\u0026rsquo;s breach database without sending the actual password anywhere.\nuse Symfony\\Component\\Validator\\Constraints as Assert; class User { #[Assert\\NotCompromisedPassword] public string $plainPassword; } The implementation uses k-anonymity: SHA-1 hash the password, send only the first five characters to the API, get back all matching hashes, check locally. The password never leaves your server. For registration forms, adding this constraint is one line and a genuinely useful security signal.\nWorkflow gets context The Workflow component existed before 4.x, but 4.3 added context propagation: the ability to pass arbitrary data through a transition and access it in listeners.\n$workflow-\u0026gt;apply($article, \u0026#39;publish\u0026#39;, [ \u0026#39;user\u0026#39; =\u0026gt; $user-\u0026gt;getUsername(), \u0026#39;reason\u0026#39; =\u0026gt; $request-\u0026gt;request-\u0026gt;get(\u0026#39;reason\u0026#39;), ]); The context arrives in TransitionEvent and gets stored alongside the marking. For audit trails, this is the difference between knowing a transition happened and knowing who triggered it and why. You can also inject context from a subscriber without touching every apply() call, which is handy for cross-cutting concerns like timestamps or current user.\nThe autowiring got smarter 4.2 added binding by type and name together. Before, you could bind by type (LoggerInterface) or by name ($logger), but not both at once. That caused problems when a service needs two different implementations of the same interface:\nservices: _defaults: bind: Psr\\Log\\LoggerInterface $orderLogger: \u0026#39;@monolog.logger.orders\u0026#39; Psr\\Log\\LoggerInterface $paymentLogger: \u0026#39;@monolog.logger.payments\u0026#39; class OrderService { public function __construct( private LoggerInterface $orderLogger, // gets monolog.logger.orders private LoggerInterface $paymentLogger, // gets monolog.logger.payments ) {} } The match requires both type and argument name to align, so there\u0026rsquo;s no risk of accidentally injecting the wrong logger.\nErrorHandler replaces the Debug component The Debug component, unchanged since 2013, had an awkward dependency on TwigBundle even for API-only apps. Any uncaught exception in a JSON API would render an HTML error page unless you wrote custom exception listeners.\n4.4 extracts this into a dedicated ErrorHandler component. For non-HTML requests, error responses now follow RFC 7807 out of the box:\n{ \u0026#34;title\u0026#34;: \u0026#34;Not Found\u0026#34;, \u0026#34;status\u0026#34;: 404, \u0026#34;detail\u0026#34;: \u0026#34;Sorry, the page you are looking for could not be found\u0026#34; } No Twig required. The format follows the Accept header: JSON for JSON requests, XML for XML requests. To customize further, you provide a normalizer via the Serializer component rather than a Twig template.\nPHP 7.4 preloading, wired in automatically PHP 7.4 introduced OPcache preloading: load files into shared memory before any requests arrive, so they\u0026rsquo;re available as compiled opcodes from the very first request. The practical gain is 30-50% faster response times with no code changes.\nThe catch is configuration: you need to specify exactly which files to preload in php.ini. Symfony 4.4 generates that file automatically in the cache directory:\n; php.ini opcache.preload=/path/to/project/var/cache/prod/App_KernelProdContainer.preload.php opcache.preload_user=www-data Run cache:warmup in production and point OPcache at the generated file. Symfony preloads the container, compiled routes, and Twig templates: the files that are read on every request and never change between deploys.\nConsole: return codes and NO_COLOR Two small things in 4.4 that honestly should have existed earlier. Commands that don\u0026rsquo;t return an integer from execute() now trigger a deprecation warning. In 5.0, the return type becomes mandatory. Returning 0 for success, non-zero for failure: standard Unix behavior, and it makes integration with process supervisors and CI pipelines unambiguous.\nprotected function execute(InputInterface $input, OutputInterface $output): int { // ... return Command::SUCCESS; // = 0 } The second: NO_COLOR environment variable support, following the convention from no-color.org. Set it and every Symfony console command drops ANSI escape codes regardless of what the terminal claims to support. Useful for CI environments that capture output as text and then choke on color codes embedded in logs.\n","permalink":"https://guillaumedelre.github.io/2020/01/04/symfony-4.4-lts-httpclient-mailer-messenger-and-the-features-that-stayed/","summary":"\u003cp\u003eSymfony 4.4 and 5.0 both landed November 21, 2019. 4.4 is the LTS: same feature set as 5.0, deprecation layer baked in, and a long support window for teams that can\u0026rsquo;t follow every release.\u003c/p\u003e\n\u003cp\u003eThe feature worth singling out arrived in 4.2 and matured through 4.3 and 4.4: \u003ccode\u003eHttpClient\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"httpclient\"\u003eHttpClient\u003c/h2\u003e\n\u003cp\u003ePHP\u0026rsquo;s built-in HTTP options (\u003ccode\u003efile_get_contents\u003c/code\u003e with stream contexts, cURL, Guzzle) each have their own model, their own quirks, and their own abstraction cost. Symfony 4.2 introduced \u003ccode\u003eHttpClient\u003c/code\u003e, a first-party HTTP client with one API over multiple transports.\u003c/p\u003e","title":"Symfony 4.4 LTS: HttpClient, Mailer, Messenger, and the features that stayed"},{"content":"The question was simple: what\u0026rsquo;s the temperature and humidity in my home office right now? Not the weather outside, not a city average — the actual conditions in the room where I spend most of my day. Opening a weather app for that felt wrong.\nA Raspberry Pi was already running on the shelf. A BME280 sensor costs around €10. This should have been a weekend project.\nIt mostly was, except for the part where I assumed reading a temperature sensor meant reading a register.\nFour wires and a chip The Bosch BME280 measures temperature, humidity, and atmospheric pressure over I²C. Four wires to the Raspberry Pi GPIO pins, enable I²C in raspi-config, and the sensor shows up at address 0x77 on the bus:\ni2cdetect -y 1 That\u0026rsquo;s the easy part. The catch is what happens next.\nYou don\u0026rsquo;t just read the temperature The BME280 doesn\u0026rsquo;t hand you 21.5°C. It gives you raw ADC values: 20-bit integers that mean absolutely nothing by themselves. To get an actual temperature, you have to:\nRead the calibration coefficients Bosch burned into the chip\u0026rsquo;s EEPROM at the factory (registers 0x88, 0xA1, 0xE1) Apply Bosch\u0026rsquo;s compensation formulas: double-precision floating point arithmetic that uses those coefficients to turn raw values into real measurements Wait for the measurement to finish by polling the status register The temperature compensation alone takes the raw value, applies a quadratic correction with three calibration constants, and spits out a value in hundredths of degrees Celsius. Pressure depends on the corrected temperature. Humidity depends on both.\nIt\u0026rsquo;s all straight from the Bosch datasheet, nothing clever. But it does mean the driver isn\u0026rsquo;t a five-liner. It\u0026rsquo;s implementing a spec, not importing a library.\nMaking it network-accessible Once the driver worked, the next question was how to get those values into Home Assistant. The simplest path: a Flask API with two endpoints.\nGET /bme280 returns the current reading as JSON. GET /bme280/publish reads the sensor and pushes the three values to an MQTT broker. A cron job on the Pi calls the publish endpoint every few minutes, and Home Assistant picks up the values in real time.\nThe MQTT discovery mechanism made the Home Assistant side almost frictionless. One mosquitto_pub command per sensor type — publishing a JSON config payload to the right topic — and the entities appear automatically in the UI. No configuration.yaml editing, no restart required.\nBME280 ──I²C──► bme280.py ──► Flask API ──MQTT──► Home Assistant The full setup guide is in the repo.\nWhat I didn\u0026rsquo;t expect The Bosch calibration is non-negotiable. I started by reading the raw temperature register directly and scaling it naively. The result was numbers that looked almost plausible and were completely wrong. The compensation algorithm isn\u0026rsquo;t optional decoration, it\u0026rsquo;s what makes the output mean anything.\nPolling beats events here. The sensor doesn\u0026rsquo;t push data, you ask it for a reading. A cron job every minute is all you need for room monitoring. Real-time streaming would be overkill and would probably wear out the sensor faster.\nMQTT discovery is underrated. Manually declaring sensors in configuration.yaml works, but auto-discovery just feels right. Publish a config payload once, and Home Assistant takes it from there. Adding a new sensor type later takes about thirty seconds.\nThe room is now 21.4°C and 47% humidity. I know this without opening anything.\nA note on the official Bosch SensorAPI While writing the driver I peeked at the official Bosch SensorAPI for reference. Two things caught my attention.\nThe Linux userspace example doesn\u0026rsquo;t actually work on a Raspberry Pi out of the box. Several contributors tripped over the same bug independently: ioctl is called before dev_addr is assigned, so the I²C device address never gets set properly. The fix is obvious once you see it, and multiple PRs documented it, but they sat open for years. Some still are.\nThen there\u0026rsquo;s PR #94 (still open as of early 2025), reporting undefined behavior in bme280_get_sensor_mode(): the left operand of a bitwise \u0026amp; is an uninitialized variable, caught by static analysis.\nThe chip itself is great. But manufacturer reference code is a starting point, not gospel. Implementing the compensation algorithm straight from the datasheet meant I understood every line of it. When a reading looks weird, there\u0026rsquo;s no mystery C library to blame.\nguillaumedelre/bme280 Python driver for the BME280 sensor — temperature, humidity, and pressure over I²C, with MQTT publishing and Home Assistant integration.\n","permalink":"https://guillaumedelre.github.io/2019/11/17/from-a-10-sensor-to-a-home-assistant-dashboard-with-a-raspberry-pi-and-mqtt/","summary":"\u003cp\u003eThe question was simple: what\u0026rsquo;s the temperature and humidity in my home office right now? Not the weather outside, not a city average — the actual conditions in the room where I spend most of my day. Opening a weather app for that felt wrong.\u003c/p\u003e\n\u003cp\u003eA Raspberry Pi was already running on the shelf. A BME280 sensor costs around €10. This should have been a weekend project.\u003c/p\u003e\n\u003cp\u003eIt mostly was, except for the part where I assumed reading a temperature sensor meant reading a register.\u003c/p\u003e","title":"From a €10 sensor to a Home Assistant dashboard with a Raspberry Pi and MQTT"},{"content":"PHP 7.3 shipped December 6th. No single killer feature. It\u0026rsquo;s a collection of quality-of-life improvements that individually feel minor but together make daily work noticeably less annoying.\nFlexible heredoc and nowdoc Until 7.3, the closing marker of a heredoc had to be at column zero. That forced awkward de-indentation in otherwise well-formatted code:\n// before $html = \u0026lt;\u0026lt;\u0026lt;HTML \u0026lt;div\u0026gt; \u0026lt;p\u0026gt;Hello\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; HTML; // had to be at column 0, ugly // after $html = \u0026lt;\u0026lt;\u0026lt;HTML \u0026lt;div\u0026gt; \u0026lt;p\u0026gt;Hello\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; HTML; The closing marker can now be indented to match the surrounding code, and that indentation is stripped from the content. This looks cosmetic. It\u0026rsquo;s not. Heredocs in nested contexts (class methods, conditionals) were visually jarring before. Now they fit.\n:1234: array_key_first() and array_key_last() This existed forever as a workaround:\n$first = array_keys($array)[0]; 7.3 adds the obvious helpers:\n$first = array_key_first($array); $last = array_key_last($array); And is_countable() to safely check before calling count() on something that might not implement Countable. Functions that should have existed years ago.\nPCRE2 The regular expression engine was migrated from PCRE to PCRE2. Mostly invisible for existing patterns, but PCRE2 is actively maintained and handles edge cases better. The main practical impact: some patterns that previously produced undefined behavior now throw errors. That\u0026rsquo;s the correct behavior, even if it surprises you on the first upgrade.\nTrailing commas in function calls 7.2 allowed trailing commas in grouped namespace imports. 7.3 extends this to function and method calls:\n$result = array_merge( $defaults, $overrides, $extras, // no more removing this comma before the closing paren ); This matters most with multiline calls. Adding or removing an argument no longer means touching the adjacent line. Diffs stay honest, rebases get a little less painful.\nReference assignments in array destructuring Array destructuring gained the ability to capture references instead of copies:\n$data = [\u0026#39;Alice\u0026#39;, 42]; [\u0026amp;$name, $age] = $data; $name = \u0026#39;Bob\u0026#39;; var_dump($data[0]); // string(3) \u0026#34;Bob\u0026#34; Nested references work too:\n[$a, [\u0026amp;$b]] = [1, [2]]; More niche than trailing commas, but the right tool when you need to alias deep into a structure without a pile of intermediate assignments.\ninstanceof with literals is now legal Previously, using instanceof with a literal on the left side was a parse error. 7.3 makes it valid:\nvar_dump(null instanceof stdClass); // bool(false) It always evaluates to false, which is exactly correct. The benefit is that code that conditionally constructs a value and then checks its type no longer needs to extract the value into a variable first. Useful in generated code and test helpers.\njson_decode() and json_encode() can throw now Before 7.3, JSON errors were silent unless you remembered to check json_last_error(). Easy to forget, easy to get wrong:\n$data = json_decode($response); if (json_last_error() !== JSON_ERROR_NONE) { // most people forgot this part } 7.3 adds JSON_THROW_ON_ERROR:\n$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR); // throws JsonException on malformed input JsonException extends RuntimeException. Catch it specifically or let it propagate. Should have worked this way from day one.\nsetcookie() with an options array The old setcookie() signature is a relic: seven positional arguments, most of which you leave as defaults just to reach the one you actually want. 7.3 adds an alternative form that takes an associative array:\nsetcookie(\u0026#39;session\u0026#39;, $token, [ \u0026#39;expires\u0026#39; =\u0026gt; time() + 3600, \u0026#39;path\u0026#39; =\u0026gt; \u0026#39;/\u0026#39;, \u0026#39;secure\u0026#39; =\u0026gt; true, \u0026#39;httponly\u0026#39; =\u0026gt; true, \u0026#39;samesite\u0026#39; =\u0026gt; \u0026#39;Lax\u0026#39;, ]); The samesite option is the real reason this was added — the old positional signature had no slot for it. session_set_cookie_params() received the same treatment, and a new session.cookie_samesite ini directive covers the default.\nhrtime() for benchmarking that actually measures time microtime() reads wall clock time. Fine for most cases. But it\u0026rsquo;s affected by NTP adjustments, and its resolution is implementation-dependent. hrtime() reads the monotonic high-resolution clock:\n$start = hrtime(true); // nanoseconds as integer doWork(); $elapsed = hrtime(true) - $start; echo $elapsed / 1e6 . \u0026#34; ms\\n\u0026#34;; Without the true argument it returns [seconds, nanoseconds] as a two-element array. Use this for microbenchmarks, or anywhere clock drift would silently corrupt your measurement.\ngc_status() — looking inside the garbage collector PHP\u0026rsquo;s cyclic garbage collector runs when a buffer of potential cycles fills up. Until 7.3 you had no easy way to see what it was actually doing. gc_status() exposes the internal state:\n$status = gc_status(); // [ // \u0026#39;runs\u0026#39; =\u0026gt; 3, // \u0026#39;collected\u0026#39; =\u0026gt; 127, // \u0026#39;threshold\u0026#39; =\u0026gt; 10001, // \u0026#39;roots\u0026#39; =\u0026gt; 42, // ] Not something most app code needs. Useful when you\u0026rsquo;re trying to figure out why memory keeps climbing under specific workloads.\nCompileError joins the exception hierarchy Parse errors have been catchable as ParseError since PHP 7.0. 7.3 introduces CompileError as the parent class for compile-time failures, with ParseError becoming a subclass of it:\nError └── CompileError └── ParseError In practice, code that catches ParseError still works. The new class just gives future compile-time errors (that aren\u0026rsquo;t parse errors) a proper home in the hierarchy.\nbcscale() as a getter The BC Math scale was always settable via bcscale($n). Getting the current scale required tracking it yourself. 7.3 makes bcscale() work without arguments:\nbcscale(4); echo bcscale(); // 4 Minor. Worth knowing if you\u0026rsquo;re writing library code that needs to respect or restore whoever called it\u0026rsquo;s scale setting.\nThe continue inside switch warning This one is a correctness fix that looks like a deprecation. In PHP, continue inside a switch has always behaved like break — it exits the switch, not the enclosing loop. Developers coming from other languages often write this expecting to skip to the next loop iteration:\nforeach ($items as $item) { switch ($item-\u0026gt;type) { case \u0026#39;skip\u0026#39;: continue; // WRONG: exits the switch, not the foreach } } 7.3 adds a warning for this pattern. The fix is continue 2 to explicitly target the enclosing loop. The behavior hasn\u0026rsquo;t changed. The silence has.\nDeprecations Case-insensitive constants declared via define() are deprecated:\ndefine(\u0026#39;MY_CONST\u0026#39;, 42, true); // third argument deprecated Passing a non-string needle to strpos(), strstr(), and related functions is deprecated. In PHP 8 these will interpret the needle as a string, not an ASCII codepoint. If you\u0026rsquo;re passing integers to these functions intentionally, chr($n) is the explicit form.\nfgetss() is deprecated — it was fgets() with HTML/PHP tags stripped. Use fgets() and strip tags explicitly if you need them stripped. The string.strip_tags stream filter goes with it.\n7.3 is the kind of release you appreciate in hindsight. Nothing individually dramatic, but after six months with it the heredoc fix alone has paid back the upgrade cost in readability. Sometimes boring is exactly right.\n","permalink":"https://guillaumedelre.github.io/2019/01/20/php-7.3-small-wins-that-add-up/","summary":"\u003cp\u003ePHP 7.3 shipped December 6th. No single killer feature. It\u0026rsquo;s a collection of quality-of-life improvements that individually feel minor but together make daily work noticeably less annoying.\u003c/p\u003e\n\u003ch2 id=\"flexible-heredoc-and-nowdoc\"\u003eFlexible heredoc and nowdoc\u003c/h2\u003e\n\u003cp\u003eUntil 7.3, the closing marker of a heredoc had to be at column zero. That forced awkward de-indentation in otherwise well-formatted code:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// before\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$html \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026lt;\u0026lt;\u0026lt;\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003eHTML\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u0026lt;div\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e        \u0026lt;p\u0026gt;Hello\u0026lt;/p\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u0026lt;/div\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003eHTML; // had to be at column 0, ugly\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e// after\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e$html = \u0026lt;\u0026lt;\u0026lt;HTML\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u0026lt;div\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e        \u0026lt;p\u0026gt;Hello\u0026lt;/p\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u0026lt;/div\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e    \u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003eHTML\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe closing marker can now be indented to match the surrounding code, and that indentation is stripped from the content. This looks cosmetic. It\u0026rsquo;s not. Heredocs in nested contexts (class methods, conditionals) were visually jarring before. Now they fit.\u003c/p\u003e","title":"PHP 7.3: small wins that add up"},{"content":"PHP 7.2 released November 30th. The headline isn\u0026rsquo;t a language feature, it\u0026rsquo;s a removal. mcrypt is gone.\nThis is good news, even if it doesn\u0026rsquo;t feel that way when you\u0026rsquo;re the one migrating.\nThe mcrypt problem mcrypt has been unmaintained since 2007. More than a decade of stagnation in a cryptography library. It was deprecated in 7.1, and 7.2 removes it entirely. The replacement is sodium, now bundled as a core extension.\nSodium is the PHP binding for libsodium, a modern cryptographic library built around safe defaults. Where mcrypt required you to pick the right cipher, mode, and padding (and get it wrong silently), sodium\u0026rsquo;s API makes dangerous choices structurally hard. sodium_crypto_secretbox() for symmetric encryption, sodium_crypto_box() for asymmetric, sodium_crypto_sign() for signatures. The names tell you what you\u0026rsquo;re doing.\nIf you have mcrypt code in production, the migration is unavoidable. Worth doing carefully too: cryptography code that \u0026ldquo;still works\u0026rdquo; can be silently broken in ways you won\u0026rsquo;t notice until it\u0026rsquo;s too late.\nThe object type hint 7.2 adds object as a parameter and return type:\nfunction serialize(object $data): string { // accepts any object } It\u0026rsquo;s broad — any object satisfies it — but it\u0026rsquo;s better than no type at all when you genuinely don\u0026rsquo;t care about the specific class. Complements the existing array, callable, and class-specific hints.\nArgon2 in password_hash $hash = password_hash($password, PASSWORD_ARGON2I); PASSWORD_BCRYPT was the default and still reasonable, but Argon2 won the Password Hashing Competition for a reason: it\u0026rsquo;s memory-hard, which makes GPU-based cracking significantly more expensive. Worth switching for new apps.\n7.2 is more of a security release than a language one. Remove mcrypt, add sodium, and you\u0026rsquo;ve moved the platform to a place where you can actually trust it with sensitive data. The language features are incremental. The infrastructure shift is not.\nParameter types you can now drop on purpose 7.2 formalizes something that was previously just a smell: when you implement an interface or override a method, you can now omit the parameter type entirely. This is valid contravariance under the Liskov substitution principle.\ninterface Serializable { public function serialize(array $data): string; } class JsonSerializer implements Serializable { public function serialize($data): string { // no type — accepts more, still valid return json_encode($data); } } It reads oddly at first. But it\u0026rsquo;s logically sound: a method that accepts anything is strictly more permissive than one that only accepts arrays. The type system agrees, even if your code reviewer raises an eyebrow.\nAbstract methods that evolve When an abstract class extends another abstract class, it can now override the abstract method with a different signature. The constraint is directional: parameters can be widened (contravariant), return types can be narrowed (covariant).\nabstract class BaseProcessor { abstract function process(string $input); } abstract class TypedProcessor extends BaseProcessor { abstract function process($input): int; // parameter widened, return type added } This was rejected before 7.2. It unlocks intermediate abstractions without forcing every leaf class to repeat the same signature.\nTrailing commas in grouped use statements Small, but I notice its absence when it\u0026rsquo;s missing. Grouped namespace imports can now have a trailing comma on the last item:\nuse App\\Services\\{ UserService, OrderService, NotificationService, // comma here — finally }; This means you can reorder or add lines without touching the previous last entry. Git diffs get cleaner, merge conflicts get rarer.\ncount() grew a conscience Before 7.2, count(null) returned 0. Silently. No warning. That\u0026rsquo;s exactly the kind of thing that buries a bug for months. Now it emits E_WARNING when you pass something that isn\u0026rsquo;t an array or a Countable object.\ncount(null); // Warning: count(): Parameter must be an array or an object that implements Countable count(42); // same count(\u0026#34;hi\u0026#34;); // same The behavior didn\u0026rsquo;t change for valid inputs. Only the silence was broken. That\u0026rsquo;s the correct direction.\nspl_object_id() — the thing you were emulating with SplObjectStorage If you\u0026rsquo;ve ever built a map keyed on object identity, you\u0026rsquo;ve written something like this:\n$storage = new SplObjectStorage(); $storage[$obj] = true; 7.2 adds spl_object_id(), which returns a unique integer for the lifetime of an object. It\u0026rsquo;s the same internal handle SplObjectStorage uses, made directly accessible:\n$id = spl_object_id($obj); // e.g. 42 $map[$id] = \u0026#39;something\u0026#39;; The integer is reused after the object is destroyed, so don\u0026rsquo;t hold onto it past the object\u0026rsquo;s lifetime. Within a well-scoped context though, it\u0026rsquo;s a cheap identity key.\nPDO: national character strings When working with databases that distinguish between regular and national character string types (Oracle, SQL Server), 7.2 adds the flags you needed:\n$stmt = $pdo-\u0026gt;prepare(\u0026#34;SELECT * FROM users WHERE name = ?\u0026#34;); $stmt-\u0026gt;bindValue(1, \u0026#39;Ångström\u0026#39;, PDO::PARAM_STR | PDO::PARAM_STR_NATL); Or set a connection-level default:\n$pdo-\u0026gt;setAttribute(PDO::ATTR_DEFAULT_STR_PARAM, PDO::PARAM_STR_NATL); PDO::PARAM_STR_NATL signals that the string is a national character type (NCHAR/NVARCHAR). Obscure, yes. Essential if you\u0026rsquo;ve ever watched your Unicode data come out mangled because the driver had no idea about the difference.\nGD got BMP support and clipping rectangles Two things worth knowing. First, BMP files are now first-class citizens in the GD extension:\n$image = imagecreatefrombmp(\u0026#39;photo.bmp\u0026#39;); imagebmp($image, \u0026#39;output.bmp\u0026#39;); Second, you can now define a clipping rectangle so that drawing operations only affect a portion of the image:\nimagesetclip($image, 10, 10, 200, 150); // x1, y1, x2, y2 // everything drawn outside this rectangle is silently ignored Neither feature reshapes how most apps work, but both replace \u0026ldquo;install an extra library\u0026rdquo; with \u0026ldquo;it\u0026rsquo;s just in core now.\u0026rdquo;\nmb_chr() and mb_ord() — Unicode\u0026rsquo;s chr() and ord() PHP has had chr() and ord() forever. They work on bytes. For Unicode code points, you were on your own. 7.2 adds the multibyte equivalents:\n$char = mb_chr(0x1F600); // returns the 😀 emoji $code = mb_ord(\u0026#39;é\u0026#39;); // returns 233 And mb_scrub(), which strips invalid byte sequences from a string rather than failing silently or throwing:\n$clean = mb_scrub($untrustedInput, \u0026#39;UTF-8\u0026#39;); Handy at any external boundary: API responses, file uploads, database reads from legacy systems.\nDeprecations worth knowing before 7.4 arrives Several things were soft-deprecated in 7.2 that will become errors in later versions. The ones most likely to bite:\n__autoload() is deprecated. If you\u0026rsquo;re still registering a global autoload function instead of using spl_autoload_register(), fix it before it becomes a fatal.\ncreate_function() is deprecated. It\u0026rsquo;s a wrapper around eval() and was always dangerous. Use a closure:\n// before $fn = create_function(\u0026#39;$x\u0026#39;, \u0026#39;return $x * 2;\u0026#39;); // after $fn = fn($x) =\u0026gt; $x * 2; each() is deprecated. The loop pattern it enabled is better written as foreach. There\u0026rsquo;s no loss here.\nparse_str() without a second argument dumps parsed values into the local symbol table — a security issue that should never have been allowed. Always pass the output variable:\nparse_str($queryString, $params); // correct The (unset) cast is deprecated because it always returns null, which you can just write as null. Completely pointless syntax that should never have existed.\n","permalink":"https://guillaumedelre.github.io/2018/01/14/php-7.2-goodbye-mcrypt-hello-sodium/","summary":"\u003cp\u003ePHP 7.2 released November 30th. The headline isn\u0026rsquo;t a language feature, it\u0026rsquo;s a removal. \u003ccode\u003emcrypt\u003c/code\u003e is gone.\u003c/p\u003e\n\u003cp\u003eThis is good news, even if it doesn\u0026rsquo;t feel that way when you\u0026rsquo;re the one migrating.\u003c/p\u003e\n\u003ch2 id=\"the-mcrypt-problem\"\u003eThe mcrypt problem\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003emcrypt\u003c/code\u003e has been unmaintained since 2007. More than a decade of stagnation in a cryptography library. It was deprecated in 7.1, and 7.2 removes it entirely. The replacement is \u003ccode\u003esodium\u003c/code\u003e, now bundled as a core extension.\u003c/p\u003e","title":"PHP 7.2: goodbye mcrypt, hello sodium"},{"content":"Symfony 4.0 released November 30, 2017, same day as 3.4. The shared release date is pretty much the only thing they have in common.\n4.0 is a different philosophy. The Symfony Standard Edition, the monolithic starting point that bundled everything and left you to remove what you didn\u0026rsquo;t need, is gone. In its place: a microframework that grows.\nFlex Symfony Flex is a Composer plugin that changes how you install Symfony packages. Before Flex, adding a bundle meant: install via Composer, register in AppKernel.php, add config to config/, update routing if needed. Four steps, all manual.\nWith Flex, installing a package runs a \u0026ldquo;recipe\u0026rdquo;: a set of automated steps that registers the bundle, generates a config skeleton, and wires routing. Installing Doctrine:\ncomposer require symfony/orm-pack That command installs the packages, creates config/packages/doctrine.yaml, adds the env variable stubs to .env, and registers everything. One command, zero manual steps.\nRecipes are community-contributed and hosted on a central server. Quality varies, but for major packages they\u0026rsquo;re maintained alongside the packages themselves.\nThe new project structure The Standard Edition layout (app/, src/, web/) is replaced by a leaner structure. Config lives in config/ split by environment. The public directory is now public/, not web/. The kernel is smaller. Controllers are plain classes, no extends Controller required.\nMore importantly, the default services.yaml uses the 3.3 autowiring defaults that make explicit service configuration mostly unnecessary. New projects start minimal and grow by adding what they actually need.\nServices private by default 4.0\u0026rsquo;s biggest BC break for existing apps: all services are private by default. You can\u0026rsquo;t fetch a service from the container directly anymore, it has to be injected. This is the right call from a DI perspective, but it breaks anything that used $this-\u0026gt;get('service_id') in controllers.\nThe migration path is AbstractController, which provides the same convenience methods through lazy service locators rather than raw container access.\nWhat was removed 4.0 is clean because it removes everything deprecated in 3.4:\nThe old form events, the old security interfaces, the old configuration formats Support for PHP \u0026lt; 7.1.3 The ClassLoader component ACL support from SecurityBundle The removals are aggressive. Apps that skipped fixing their 3.4 deprecations will have a rough time. Apps that did the cleanup beforehand have a smooth path.\nSymfony 4.0 is the reset the framework needed. The Standard Edition had accumulated years of \u0026ldquo;this is how it\u0026rsquo;s done\u0026rdquo; that Flex sweeps away in one shot.\nEnvironment variables that actually know their type Before 3.4 and 4.0, environment variables were strings. Always. Trying to inject DATABASE_PORT into an int parameter would silently break or blow up with a type error. The fix was ugly: cast in PHP or avoid typed parameters entirely.\n4.0 ships with env var processors that handle the conversion at the container level:\nparameters: app.connection.port: \u0026#39;%env(int:DATABASE_PORT)%\u0026#39; app.debug_mode: \u0026#39;%env(bool:APP_DEBUG)%\u0026#39; Beyond casting, processors can decode base64, load from files, parse JSON, or resolve container parameters within a value. The json:file: combination turned into a clean pattern for loading secrets from mounted files in containerized deployments:\nparameters: env(SECRETS_FILE): \u0026#39;/run/secrets/app.json\u0026#39; app.secrets: \u0026#39;%env(json:file:SECRETS_FILE)%\u0026#39; You can also write custom processors by implementing EnvVarProcessorInterface and tagging the service. Looks like overkill until the day you need it.\nTagged services without the boilerplate Before 4.0, collecting all services with a given tag into one service meant writing a compiler pass. Forty lines of PHP to say \u0026ldquo;give me everything tagged app.handler.\u0026rdquo;\n3.4 introduced the !tagged YAML shorthand, and 4.0 carries it forward:\nservices: App\\HandlerCollection: arguments: [!tagged app.handler] The collection is lazy by default when type-hinted as iterable, so services aren\u0026rsquo;t instantiated until you actually iterate. This replaced a whole category of compiler passes that existed for the sole purpose of building lists.\nPHP as a configuration format YAML has been the default for so long it feels required. It isn\u0026rsquo;t. 4.0 ships with PHP-based configuration using a fluent interface:\n// config/services.php return function (ContainerConfigurator $container) { $services = $container-\u0026gt;services() -\u0026gt;defaults() -\u0026gt;autowire() -\u0026gt;autoconfigure(); $services-\u0026gt;load(\u0026#39;App\\\\\u0026#39;, \u0026#39;../src/\u0026#39;) -\u0026gt;exclude(\u0026#39;../src/{Entity,Repository}\u0026#39;); }; Same approach works for routes. The practical benefit: IDE autocompletion, type checking, and actual PHP logic in configuration without the % parameter interpolation syntax. YAML isn\u0026rsquo;t going anywhere, but now you have a choice.\nArgon2i, because bcrypt was already aging Symfony 3.4/4.0 added Argon2i support, winner of the 2015 Password Hashing Competition. Configuration is one line:\nsecurity: encoders: App\\Entity\\User: algorithm: argon2i Argon2i is built into PHP 7.2+ and available via the sodium extension on earlier versions. Like bcrypt, it\u0026rsquo;s self-salting, no need to manage salt columns. Unlike bcrypt, it\u0026rsquo;s designed to resist GPU-based attacks with configurable memory usage. If you\u0026rsquo;re starting a new project on 4.0, there\u0026rsquo;s really no reason to reach for bcrypt.\nThe form layer gets a Bootstrap 4 theme The existing Bootstrap 3 form theme has been around since Symfony 2.x. Bootstrap 4 ships as a first-class option in 4.0:\ntwig: form_themes: [\u0026#39;bootstrap_4_layout.html.twig\u0026#39;] More useful in practice: the tel and color HTML5 input types are now available as TelType and ColorType form types. Before, you had to write custom types or override raw widgets for those.\nLocal service binding Global _defaults bindings apply to all services. Sometimes you need a binding scoped to a specific class or namespace, like different logger instances for different subsystems.\n4.0 supports per-service bind for exactly that:\nservices: App\\Service\\OrderService: bind: Psr\\Log\\LoggerInterface: \u0026#39;@monolog.logger.orders\u0026#39; App\\Service\\PaymentService: bind: Psr\\Log\\LoggerInterface: \u0026#39;@monolog.logger.payments\u0026#39; Same interface, two different implementations, no factory, no extra configuration. Small feature, but it kills a whole category of awkward workarounds.\n","permalink":"https://guillaumedelre.github.io/2018/01/14/symfony-4.0-flex-and-the-end-of-the-standard-edition/","summary":"\u003cp\u003eSymfony 4.0 released November 30, 2017, same day as 3.4. The shared release date is pretty much the only thing they have in common.\u003c/p\u003e\n\u003cp\u003e4.0 is a different philosophy. The Symfony Standard Edition, the monolithic starting point that bundled everything and left you to remove what you didn\u0026rsquo;t need, is gone. In its place: a microframework that grows.\u003c/p\u003e\n\u003ch2 id=\"flex\"\u003eFlex\u003c/h2\u003e\n\u003cp\u003eSymfony Flex is a Composer plugin that changes how you install Symfony packages. Before Flex, adding a bundle meant: install via Composer, register in \u003ccode\u003eAppKernel.php\u003c/code\u003e, add config to \u003ccode\u003econfig/\u003c/code\u003e, update routing if needed. Four steps, all manual.\u003c/p\u003e","title":"Symfony 4.0: Flex and the end of the Standard Edition"},{"content":"Symfony 3.4 and 4.0 were released the same day: November 30, 2017. That\u0026rsquo;s not a coincidence, it\u0026rsquo;s the strategy.\n3.4 is not a feature release. It ships with exactly the same features as 3.3, plus every deprecation warning that 4.0 will enforce. Its whole purpose is to be the migration tool: upgrade from 3.3 to 3.4, fix what\u0026rsquo;s in your logs, then step to 4.0 cleanly.\nWhy LTS releases matter in Symfony\u0026rsquo;s model Symfony releases a new minor version every six months. That pace would be brutal for production apps to follow, so the project designates every fourth minor as an LTS: three years of bug fixes, four of security fixes. Which means teams can target 3.4 and mostly stop thinking about upgrades for a while.\n3.4 is the last LTS of the 3.x line. If you\u0026rsquo;re still on 2.x or early 3.x, this is your landing zone.\nThe deprecation layer Every feature that 4.0 removes is deprecated in 3.4. Run your app on 3.4 with deprecation notices enabled and your logs become a to-do list. The common ones:\nServices without explicit visibility (public/private) generate warnings — 4.0 makes all services private by default ControllerTrait is deprecated in favor of AbstractController The old security authenticator interfaces are marked for removal YAML-only service configuration without autowiring annotations triggers warnings The intended workflow: upgrade to 3.4, run the test suite with deprecation notices as errors (SYMFONY_DEPRECATIONS_HELPER=max[self]=0 in PHPUnit), fix everything that fails. After that, the upgrade to 4.0 is basically mechanical.\nThe support window 3.4 LTS receives bug fixes until November 2020 and security fixes until November 2021. That\u0026rsquo;s a comfortable runway for apps that can\u0026rsquo;t follow every release. The cost: staying on the 3.x architecture, with no Flex, no micro-framework structure, no zero-config autowiring by default.\nThe bridge is there. Whether and when you cross it is a business decision, not a technical one.\nServices go private 3.4 flipped the default visibility of services from public to private. Before this, $container-\u0026gt;get('app.my_service') was perfectly normal code. After this, it\u0026rsquo;s an anti-pattern that generates a deprecation warning in 3.4 and breaks entirely in 4.0.\nThe reasoning is simple: fetching services directly from the container hides dependencies and defeats static analysis. If you inject through the constructor, the container can optimize the graph, tree-shake unused services, and catch mistakes at compile time. If you pull them at runtime, it can\u0026rsquo;t.\nFor apps already using autowiring, the migration is usually small. The sticky point is controllers that extend Controller and call $this-\u0026gt;get('something'). The fix is switching to AbstractController, which provides the same shortcuts but through lazy service locators instead of raw container access.\nFor services that genuinely need to be public (accessed from legacy code or functional tests), mark them explicitly:\nservices: App\\Service\\LegacyAdapter: public: true Binding scalar arguments once A classic friction point with autowiring: scalar constructor arguments. If ten services all need $projectDir, you had to configure each one individually. The bind key under _defaults fixes that:\nservices: _defaults: autowire: true autoconfigure: true bind: $projectDir: \u0026#39;%kernel.project_dir%\u0026#39; $mailerDsn: \u0026#39;%env(MAILER_DSN)%\u0026#39; Psr\\Log\\LoggerInterface $auditLogger: \u0026#39;@monolog.logger.audit\u0026#39; Any service with a constructor parameter named $projectDir gets the bound value automatically. You can also bind by type-hint, which handles the common case where multiple logger channels exist and you need a specific one. Bindings in _defaults apply to all services in the file; you can override per-service if needed.\nInjecting tagged services without a compiler pass Before 3.4, collecting all services with a given tag meant writing a compiler pass. Now there\u0026rsquo;s a YAML shorthand:\nservices: App\\Chain\\TransformerChain: arguments: $transformers: !tagged app.transformer class TransformerChain { public function __construct(private iterable $transformers) {} } The !tagged notation creates an IteratorArgument: services are lazily instantiated as you iterate, so unused transformers never get built. For ordering, add a priority attribute to the tag definition on each service.\nA logger that ships with the framework No Monolog? No problem. Symfony 3.4 includes a PSR-3 logger that writes to php://stderr by default. Autowire it with Psr\\Log\\LoggerInterface:\nuse Psr\\Log\\LoggerInterface; class MyService { public function __construct(private LoggerInterface $logger) {} public function doSomething(): void { $this-\u0026gt;logger-\u0026gt;warning(\u0026#39;Something questionable happened\u0026#39;, [\u0026#39;context\u0026#39; =\u0026gt; \u0026#39;here\u0026#39;]); } } The default minimum level is warning. The target is container and Kubernetes workloads where stderr is the natural log sink. It\u0026rsquo;s deliberately minimal: no handlers, no processors, no channels. When you need those, install Monolog.\nGuard authenticators got a supports() method The Guard component\u0026rsquo;s getCredentials() method was pulling double duty: deciding whether the authenticator should handle the request, and extracting the credentials. Returning null was the signal to skip. That made the contract messy.\n3.4 added supports() to separate those concerns:\nclass ApiTokenAuthenticator extends AbstractGuardAuthenticator { public function supports(Request $request): bool { return $request-\u0026gt;headers-\u0026gt;has(\u0026#39;X-API-TOKEN\u0026#39;); } public function getCredentials(Request $request): array { // Only called when supports() returns true. // Must always return credentials now. return [\u0026#39;token\u0026#39; =\u0026gt; $request-\u0026gt;headers-\u0026gt;get(\u0026#39;X-API-TOKEN\u0026#39;)]; } } The old GuardAuthenticatorInterface is deprecated. The practical benefit: base classes can implement shared getUser() and checkCredentials() logic, while subclasses only override supports() and getCredentials(). One responsibility each.\nTwo new debug commands debug:autowiring replaces the old debug:container --types for discovering which type-hints work with autowiring:\n$ bin/console debug:autowiring log Autowirable Services ==================== Psr\\Log\\LoggerInterface alias to monolog.logger Psr\\Log\\LoggerInterface $auditLogger alias to monolog.logger.audit Pass a keyword to filter. No more guessing whether it\u0026rsquo;s LoggerInterface or Logger.\ndebug:form gives you the same introspection capability for form types:\n$ bin/console debug:form App\\Form\\OrderType label_attr Option: label_attr Required: false Default: [] Allowed types: array Without arguments it lists all registered form types, extensions, and guessers. With a type name and option name it shows every constraint on that option. Before this, you either read the source or trial-and-errored your way through.\nSessions got stricter by default 3.4 implements PHP 7.0\u0026rsquo;s SessionUpdateTimestampHandlerInterface, which brings two things: lazy session writes (only written when data actually changed) and strict session ID validation (IDs that don\u0026rsquo;t exist in the store are rejected rather than silently created, which blocks a class of session fixation attacks).\nThe old WriteCheckSessionHandler, NativeSessionHandler, and NativeProxy classes are deprecated. The MemcacheSessionHandler (note: not Memcached) is gone too, since the underlying PECL extension stopped receiving PHP 7 updates.\nTwig form themes can now be scoped Global form themes apply to every form in the app. If one form needs a completely different look, you had no clean way to opt out. The only keyword handles that:\n{% raw %}{% form_theme orderForm with [\u0026#39;form/order_layout.html.twig\u0026#39;] only %}{% endraw %} The only keyword disables all global themes for that form, including the base form_div_layout.html.twig. Your custom theme then needs to either provide all the blocks it uses, or explicitly pull them in with {% raw %}{% use 'form_div_layout.html.twig' %}{% endraw %}.\nOverriding bundle templates without infinite loops Overriding a bundle template that you also need to extend used to cause a circular reference error. Override @TwigBundle/Exception/error404.html.twig and also try to inherit from it? The old namespace resolution would follow your override and loop forever.\n3.4 introduced the @! prefix to explicitly reference the original bundle template, bypassing any overrides:\n{% raw %}{# templates/bundles/TwigBundle/Exception/error404.html.twig #} {% extends \u0026#39;@!Twig/Exception/error404.html.twig\u0026#39; %} {% block title %}Page not found{% endblock %}{% endraw %} @TwigBundle resolves to your override if one exists. @!TwigBundle always resolves to the original. Override-and-extend, without the gymnastics.\n","permalink":"https://guillaumedelre.github.io/2018/01/12/symfony-3.4-lts-the-bridge-you-actually-want-to-cross/","summary":"\u003cp\u003eSymfony 3.4 and 4.0 were released the same day: November 30, 2017. That\u0026rsquo;s not a coincidence, it\u0026rsquo;s the strategy.\u003c/p\u003e\n\u003cp\u003e3.4 is not a feature release. It ships with exactly the same features as 3.3, plus every deprecation warning that 4.0 will enforce. Its whole purpose is to be the migration tool: upgrade from 3.3 to 3.4, fix what\u0026rsquo;s in your logs, then step to 4.0 cleanly.\u003c/p\u003e\n\u003ch2 id=\"why-lts-releases-matter-in-symfonys-model\"\u003eWhy LTS releases matter in Symfony\u0026rsquo;s model\u003c/h2\u003e\n\u003cp\u003eSymfony releases a new minor version every six months. That pace would be brutal for production apps to follow, so the project designates every fourth minor as an LTS: three years of bug fixes, four of security fixes. Which means teams can target 3.4 and mostly stop thinking about upgrades for a while.\u003c/p\u003e","title":"Symfony 3.4 LTS: the bridge you actually want to cross"},{"content":"Symfony 3.3 shipped May 29th. It\u0026rsquo;s the release that changed how I think about service configuration. In hindsight, it was basically a preview of what 4.0 would make the new default.\nThe autowiring problem Before 3.3, Symfony\u0026rsquo;s DI was powerful but verbose. Every service had to be declared explicitly in services.yml with its arguments listed. Autowiring existed since 3.1, but it was opt-in per service and had enough edge cases to bite you. Teams either wrote mountains of YAML or leaned on third-party bundles to cut the noise.\n3.3 rewrote the defaults. With autoconfigure: true and autowire: true set once in the defaults section, every class in src/ becomes a service automatically, and its constructor dependencies are resolved by type. What used to take twenty lines of YAML now takes zero:\nservices: _defaults: autowire: true autoconfigure: true App\\: resource: \u0026#39;../src/\u0026#39; That single block is the entire service configuration for most apps. The framework discovers services, injects dependencies, and applies tags (command, event subscriber, voter\u0026hellip;) based on the interfaces each class implements.\ninstanceof conditionals The instanceof keyword in service configuration handles the tagging that previously required explicit declaration:\nservices: _instanceof: Symfony\\Component\\EventDispatcher\\EventSubscriberInterface: tags: [\u0026#39;kernel.event_subscriber\u0026#39;] Any service implementing EventSubscriberInterface gets the tag automatically. Same for Command, Voter, MessageHandlerInterface. The boilerplate evaporates.\nDotenv component Before 3.3, Symfony had no built-in way to load .env files. The standard answer was a third-party package. The new Dotenv component reads .env and populates $_ENV and $_SERVER, making environment-based configuration a first-class citizen at last.\nService discovery from the filesystem The resource option ties it all together. Instead of registering every class individually, you point the container at a directory and it scans for PSR-4 classes:\nservices: App\\: resource: \u0026#39;../src/\u0026#39; exclude: \u0026#39;../src/{Entity,Migrations}\u0026#39; Every class found becomes a service with its FQCN as the service ID. The exclude option handles things like Doctrine entities that you don\u0026rsquo;t want the container touching. And no, it\u0026rsquo;s not magic: it\u0026rsquo;s a filesystem scan at compile time, so the cost is paid once during cache warmup, not per request.\nWhen you need a subset of the container Service locators solve a specific tension: some services legitimately need lazy access to a variable set of other services, but injecting the full container is an anti-pattern — it hides dependencies and defeats static analysis. The solution is a locator that explicitly declares what it contains.\nservices: App\\Handler\\HandlerLocator: class: Symfony\\Component\\DependencyInjection\\ServiceLocator tags: [\u0026#39;container.service_locator\u0026#39;] arguments: - App\\Command\\CreateOrder: \u0026#39;@App\\Handler\\CreateOrderHandler\u0026#39; App\\Command\\CancelOrder: \u0026#39;@App\\Handler\\CancelOrderHandler\u0026#39; App\\Bus\\CommandBus: arguments: [\u0026#39;@App\\Handler\\HandlerLocator\u0026#39;] The locator implements PSR-11\u0026rsquo;s ContainerInterface, so the receiving class type-hints against Psr\\Container\\ContainerInterface. Services inside it are lazily instantiated: if a given handler never gets called during a request, it never gets built.\nAnd speaking of PSR-11: Symfony 3.3 made its container implement that standard. Which means any library expecting a PSR-11 container now works directly with Symfony\u0026rsquo;s container, no adapter needed.\nRouting got faster The routing component rewrote how it generates dump files. In an app with 900 routes, URL matching dropped from 7.5ms to 2.5ms per match: a 66% reduction. The optimizations live in the compiled output, not the runtime path, so existing route definitions benefit automatically after a cache clear.\nFinding the project root without counting directory separators Before 3.3, getting the project root meant using the delightfully awkward %kernel.root_dir%/../ pattern, because getRootDir() pointed at the app/ directory. The new getProjectDir() method walks up from the kernel file until it finds composer.json and returns that directory.\n// Before $path = $this-\u0026gt;getParameter(\u0026#39;kernel.root_dir\u0026#39;) . \u0026#39;/../var/data.db\u0026#39;; // After $path = $this-\u0026gt;getParameter(\u0026#39;kernel.project_dir\u0026#39;) . \u0026#39;/var/data.db\u0026#39;; The corresponding parameter is %kernel.project_dir%. If you deploy without composer.json, you can override the method in your kernel class and return whatever path makes sense.\nFlash messages without touching the session object The old way of iterating flash messages in Twig required reaching through app.session.flashbag, which also forced the session to start whether or not there were any messages. The new app.flashes helper avoids both:\n{% raw %}{% for label, messages in app.flashes %} {% for message in messages %} \u0026lt;div class=\u0026#34;flash-{{ label }}\u0026#34;\u0026gt;{{ message }}\u0026lt;/div\u0026gt; {% endfor %} {% endfor %}{% endraw %} If there are no flash messages, the session never starts. You can also filter by type: app.flashes('error') returns only error messages.\nThe encode-password command grew a brain The security:encode-password console command got smarter. Instead of requiring you to pass the user class as an argument, it now lists the configured user classes and lets you pick:\n$ bin/console security:encode-password For which user class would you like to encode a password? [0] App\\Entity\\User [1] App\\Entity\\AdminUser It also normalizes encoder configuration to handle edge cases with email-format usernames that the previous version would silently corrupt by replacing @ with underscores. Nice catch.\nHTTP/2 push and resource hints The WebLink component handles the Link HTTP header, which tells browsers (and HTTP/2 proxies) to preload, prefetch, or preconnect to resources before the page even asks for them. It comes as a set of Twig functions:\n{% raw %}{{ preload(\u0026#39;/fonts/custom.woff2\u0026#39;, { as: \u0026#39;font\u0026#39;, crossorigin: true }) }} {{ prefetch(\u0026#39;/api/next-page-data.json\u0026#39;) }} {{ dns_prefetch(\u0026#39;https://fonts.googleapis.com\u0026#39;) }}{% endraw %} Each call adds a corresponding Link header to the response. For apps behind an HTTP/2-capable proxy, this can trigger server push before the browser has even parsed the HTML. You enable it in config.yml:\nframework: web_link: enabled: true Deprecations you can actually trust Container compilation used to generate deprecation warnings that vanished on the next page load because the cached container was already built. 3.3 persists those messages to disk and surfaces them in the web debug toolbar alongside request-phase deprecations. If a class is being deprecated during service compilation, you\u0026rsquo;ll see it without having to nuke the cache first.\nWhat this meant for 4.0 3.3\u0026rsquo;s autowiring defaults are exactly what Symfony 4.0 shipped as the new standard project structure. The services.yaml in every new Symfony 4 project is essentially the snippet above. If you had already picked up what 3.3 introduced, 4.0\u0026rsquo;s \u0026ldquo;new way\u0026rdquo; felt familiar rather than foreign.\nThe direction was clear: less configuration, more convention. Let PHP figure out what to wire together.\n","permalink":"https://guillaumedelre.github.io/2017/07/13/symfony-3.3-when-services-stopped-being-a-configuration-nightmare/","summary":"\u003cp\u003eSymfony 3.3 shipped May 29th. It\u0026rsquo;s the release that changed how I think about service configuration. In hindsight, it was basically a preview of what 4.0 would make the new default.\u003c/p\u003e\n\u003ch2 id=\"the-autowiring-problem\"\u003eThe autowiring problem\u003c/h2\u003e\n\u003cp\u003eBefore 3.3, Symfony\u0026rsquo;s DI was powerful but verbose. Every service had to be declared explicitly in \u003ccode\u003eservices.yml\u003c/code\u003e with its arguments listed. Autowiring existed since 3.1, but it was opt-in per service and had enough edge cases to bite you. Teams either wrote mountains of YAML or leaned on third-party bundles to cut the noise.\u003c/p\u003e","title":"Symfony 3.3: when services stopped being a configuration nightmare"},{"content":"The rule was simple: whoever breaks the CI build owes the team a coffee. It worked fine for a while. Then someone suggested we needed something with more immediate feedback. Something physical. Something that fires.\nA Dream Cheeky Thunder appeared on a desk shortly after. Four foam missiles, a USB cable, and a very clear team consensus: hook it to the cluster, wire it to the build pipeline, and let the CI decide who deserves a volley.\nThe launcher needed to respond to HTTP calls from anywhere on the network. No driver, no GUI, no manual aiming. Just an endpoint that makes it shoot in the direction of the guilty party\u0026rsquo;s desk.\nThis is the story of dream-cheeky-thunder.\nNo SDK, no docs, no problem Dream Cheeky never published a protocol spec. The launcher speaks raw USB HID, and the only starting point was a vendored Python script from 2012 floating around in forum threads. Vendor ID 0x2123, product ID 0x1010, and a handful of control bytes that someone had reverse engineered years before.\nThat was enough. The protocol is simple: send a byte sequence to move the motors, send another to fire. The tricky part is that the launcher has no position feedback. No encoders, no limit switches beyond the physical hard stops at the extremes. You drive it blind.\nFrom USB to HTTP The CI pipeline needed to trigger the launcher over the network. A local script wasn\u0026rsquo;t going to cut it — the launcher had to be reachable from any machine on the cluster, including the build server. So: a REST API.\nFastAPI was the obvious choice. The targeting flow from the CI side ends up being three HTTP calls:\ncurl -X POST http://localhost:8000/park # reset to known position curl -X POST http://localhost:8000/yaw/20 # rotate toward guilty desk curl -X POST \u0026#34;http://localhost:8000/fire?shots=2\u0026#34; The /park call matters more than it looks. Since the launcher has no position feedback, the server estimates the current angle by tracking how long the motors have been running. That estimate drifts. Bumping the hardware, interrupting a command, or just the imprecision of time-based tracking — they all accumulate. Parking drives both motors against the physical hard stops at full sweep, which guarantees alignment regardless of what the server thinks it knows. Skip it, and your aim is a guess.\nThe full API reference is in the repo. There\u0026rsquo;s also a web UI if you prefer clicking over curl.\nDocker knows nothing about USB Running this in a Docker container on the cluster was where the fun really started: containers don\u0026rsquo;t see USB devices by default.\nThe devices mount in compose.yaml exposes the USB bus to the container:\ndevices: - /dev/bus/usb:/dev/bus/usb Not enough. First run came back with USBError: [Errno 13] Access denied. The device node is there inside the container, but it inherits permissions from the host, and on the host only root can open it by default.\nThe fix is a udev rule. Drop one file into /etc/udev/rules.d/, and the kernel sets the right group and permissions when the device plugs in. After that, the container user can open it without needing elevated privileges. The rule ships with the project, setup instructions are in the docs.\nWSL2 made it interesting Half the team runs Windows with Docker Desktop on WSL2. That\u0026rsquo;s where things got creative.\nWSL2 has no access to USB devices by default: the Windows kernel holds them, and the devices mount alone does nothing because WSL2 simply doesn\u0026rsquo;t see the hardware. The fix is usbipd-win, which forwards the USB device from Windows into the WSL2 kernel over IP. Once that\u0026rsquo;s done, the Linux path works exactly the same: udev rule, devices mount, done.\nThe attachment doesn\u0026rsquo;t survive reboots, though. usbipd v4+ added a policy mechanism that automates reconnection, which killed the \u0026ldquo;it worked yesterday\u0026rdquo; mystery that had been annoying us for days.\nWhat actually surprised us Time-based positioning works well enough. No encoders meant we went in expecting the angle tracking to be basically useless. Turns out, parking before every sequence kept it accurate enough to reliably aim at a specific desk. Not millimeter precision, but foam missile precision is fine.\nThe devices mount is necessary but not sufficient. The permission error was confusing precisely because the device was clearly visible inside the container. The udev rule is the bit most tutorials quietly skip.\nThe coffee rule was never the same after this. Once the launcher was wired to the pipeline, broken builds suddenly became a lot more motivating to fix.\nguillaumedelre/dream-cheeky-thunder FastAPI + Docker + PyUSB — HTTP control for the Dream Cheeky Thunder USB missile launcher. Pull requests welcome, especially if you have a better angle calibration approach.\n","permalink":"https://guillaumedelre.github.io/2017/02/21/controlling-a-usb-missile-launcher-over-http-with-fastapi-and-docker/","summary":"\u003cp\u003eThe rule was simple: whoever breaks the CI build owes the team a coffee. It worked fine for a while. Then someone suggested we needed something with more immediate feedback. Something physical. Something that fires.\u003c/p\u003e\n\u003cp\u003eA \u003ca href=\"http://www.dreamcheeky.com/thunder-missile-launcher\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eDream Cheeky Thunder\u003c/a\u003e appeared on a desk shortly after. Four foam missiles, a USB cable, and a very clear team consensus: hook it to the cluster, wire it to the build pipeline, and let the CI decide who deserves a volley.\u003c/p\u003e","title":"Controlling a USB missile launcher over HTTP with FastAPI and Docker"},{"content":"A timestamp coming back from the database one hour off. Not every time. Only when the dev server runs in Europe/Paris and CI runs in UTC. The kind of bug that disappears when you look for it and comes back in production on a Friday evening.\nThe problem isn\u0026rsquo;t in the business logic. It\u0026rsquo;s in what Doctrine quietly does with dates.\nWhat Doctrine does by default When you declare a datetime field in a Doctrine entity, the conversion between PHP and the database goes through DateTimeType. That class calls format() on your DateTime object to write to the database, and DateTime::createFromFormat() to read it back. No mention of timezone anywhere.\nIf your PHP object is in Europe/Paris, Doctrine formats 2017-01-15 11:30:00 and writes it as-is. If the server reading that field is in UTC, it gets 2017-01-15 11:30:00 and interprets it as UTC. One hour has evaporated in the round trip, without a single error message.\nThe Doctrine docs cover this, suggesting custom types as the fix. What they mention in passing is that you can give those custom types the same name as the built-in ones. That detail changes everything.\nReplace, don\u0026rsquo;t add Most custom Doctrine type examples introduce a new name: utc_datetime, app_date, and so on. You then annotate every field with type: 'utc_datetime' in the entities. It works, but it\u0026rsquo;s tedious and doesn\u0026rsquo;t protect against a forgotten type: 'datetime'.\nThe other option: register the custom type under the name datetime. Doctrine replaces its own type with yours, everywhere, no exceptions. Every datetime field across all entities goes through your logic, without changing a single annotation.\nThat\u0026rsquo;s what we just shipped across our PHP microservices platform. Here\u0026rsquo;s what it looks like.\nThe shared trait Both types (date and datetime) share the same conversion logic through a trait:\ntrait UTCDate { private \\DateTimeZone $utc; public function convertToPHPValue($value, AbstractPlatform $platform): ?\\DateTime { if (null === $value || $value instanceof \\DateTime) { return $value; } $format = $this-\u0026gt;getFormat($platform); $converted = \\DateTime::createFromFormat($format, $value, $this-\u0026gt;getUtc()); if (!$converted) { throw new \\RuntimeException( sprintf(\u0026#39;Could not convert database value \u0026#34;%s\u0026#34; to DateTime using format \u0026#34;%s\u0026#34;.\u0026#39;, $value, $format) ); } $this-\u0026gt;postConvert($converted); return $converted; } abstract protected function getFormat(AbstractPlatform $platform): string; private function getUtc(): \\DateTimeZone { if (empty($this-\u0026gt;utc)) { $this-\u0026gt;utc = new \\DateTimeZone(\u0026#39;UTC\u0026#39;); } return $this-\u0026gt;utc; } } The key: \\DateTime::createFromFormat() receives an explicit UTC timezone. The raw value from the database is interpreted as UTC, regardless of what the PHP server\u0026rsquo;s timezone is set to.\nUTCDateTimeType For datetime fields, the write path also needs to enforce UTC:\nclass UTCDateTimeType extends DateTimeType { use UTCDate; #[\\Override] public function convertToPHPValue($value, AbstractPlatform $platform): ?\\DateTime { if (null === $value || $value instanceof \\DateTimeInterface) { return parent::convertToPHPValue($value, $platform); } return parent::convertToPHPValue(\u0026#34;$value+0000\u0026#34;, $platform); } #[\\Override] public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string { if ($value instanceof \\DateTime) { $value-\u0026gt;setTimezone($this-\u0026gt;getUtc()); } return parent::convertToDatabaseValue($value, $platform); } #[\\Override] protected function getFormat(AbstractPlatform $platform): string { return $platform-\u0026gt;getDateTimeFormatString(); } protected function postConvert(\\DateTime $converted): void {} } On read (convertToPHPValue), if the value is a raw string, we append +0000 before delegating to the parent. The parent then uses that timezone suffix to create the PHP object correctly.\nOn write (convertToDatabaseValue), we force the DateTime to UTC before formatting. What goes into the database is always UTC.\nUTCDateType For date columns (no time component), same approach with one extra step:\nclass UTCDateType extends DateType { use UTCDate; #[\\Override] protected function getFormat(AbstractPlatform $platform): string { return $platform-\u0026gt;getDateFormatString(); } protected function postConvert(\\DateTime $converted): void { $converted-\u0026gt;setTime(0, 0, 0); } } The postConvert() method resets the time to 00:00:00 after parsing. Without it, a date field might come back with 23:59:59 or 00:00:00+02:00 depending on the server\u0026rsquo;s timezone, which breaks comparisons and ordering.\nRegistering in Symfony The decisive part: declaring the types under their built-in names in config/packages/doctrine.yaml.\ndoctrine: dbal: types: date: class: App\\Doctrine\\DBAL\\Types\\UTCDateType datetime: class: App\\Doctrine\\DBAL\\Types\\UTCDateTimeType That\u0026rsquo;s it. Doctrine swaps out its own implementations for yours. Existing entities don\u0026rsquo;t change, migrations don\u0026rsquo;t move, annotations stay type: Types::DATETIME_MUTABLE. The behavior changes globally, without friction.\n12 microservices, 89 columns, one config block These two types are now running across 12 independent microservices, each with its own Doctrine config, covering 89 production columns. CI servers run in UTC, dev machines in Europe/Paris, data travels between them without shifting. It\u0026rsquo;s not spectacular. It\u0026rsquo;s just reliable.\nThe real lesson isn\u0026rsquo;t technical: an unresolved timezone issue is a data integrity issue. Offsets accumulate silently, comparisons go wrong, exports become inaccurate. Two lines of config and three classes can prevent that permanently.\n","permalink":"https://guillaumedelre.github.io/2017/02/19/enforcing-utc-in-doctrine-without-touching-your-entities/","summary":"\u003cp\u003eA timestamp coming back from the database one hour off. Not every time. Only when the dev server runs in \u003ccode\u003eEurope/Paris\u003c/code\u003e and CI runs in UTC. The kind of bug that disappears when you look for it and comes back in production on a Friday evening.\u003c/p\u003e\n\u003cp\u003eThe problem isn\u0026rsquo;t in the business logic. It\u0026rsquo;s in what Doctrine quietly does with dates.\u003c/p\u003e\n\u003ch2 id=\"what-doctrine-does-by-default\"\u003eWhat Doctrine does by default\u003c/h2\u003e\n\u003cp\u003eWhen you declare a \u003ccode\u003edatetime\u003c/code\u003e field in a Doctrine entity, the conversion between PHP and the database goes through \u003ccode\u003eDateTimeType\u003c/code\u003e. That class calls \u003ccode\u003eformat()\u003c/code\u003e on your \u003ccode\u003eDateTime\u003c/code\u003e object to write to the database, and \u003ccode\u003eDateTime::createFromFormat()\u003c/code\u003e to read it back. No mention of timezone anywhere.\u003c/p\u003e","title":"Enforcing UTC in Doctrine without touching your entities"},{"content":"PHP 7.1 shipped December 1st. No 2x performance headline, no engine rewrite. It fills in the gaps that 7.0 left in the type system, and those gaps were genuinely annoying.\nNullable types 7.0 let you declare string $name as a parameter type. What it didn\u0026rsquo;t let you do was say \u0026ldquo;this can also be null\u0026rdquo;. You had to drop the type hint entirely or hack around it. 7.1 adds ? prefix:\nfunction findUser(?int $id): ?User { if ($id === null) return null; return $this-\u0026gt;repository-\u0026gt;find($id); } This sounds minor. It\u0026rsquo;s not. Nullable types make the difference between a signature that tells you what a function does and one that lies by omission. Every codebase I\u0026rsquo;ve worked on has functions that can return null. Now you can actually say so instead of hiding it in a docblock.\nVoid return type The complement to nullable: a function that intentionally returns nothing:\npublic function process(Order $order): void { $this-\u0026gt;dispatcher-\u0026gt;dispatch(new OrderProcessed($order)); } void makes the intent explicit and prevents accidentally returning a value from a function that shouldn\u0026rsquo;t. Combined with nullable types, PHP\u0026rsquo;s type system in 7.1 is quite a bit more expressive than 7.0.\nClass constant visibility A small but welcome fix. Constants in classes were always public before 7.1. Now:\nclass Config { private const DB_PASSWORD = \u0026#39;secret\u0026#39;; protected const VERSION = \u0026#39;2.0\u0026#39;; public const MAX_RETRIES = 3; } Keeping implementation details private matters. This should have existed from the start.\nCatching multiple exceptions try { // ... } catch (InvalidArgumentException | RuntimeException $e) { // handle both } Saves a duplicate catch block when two exceptions need identical handling. Simple, useful.\nDestructuring arrays without list() list() has been in PHP since 4.0 and always felt slightly out of place syntactically. 7.1 adds a shorthand using [] that reads much more naturally:\n[$first, $second] = $coordinates; foreach ($rows as [$id, $name, $email]) { // ... } It also gains key support, which makes destructuring associative arrays finally usable:\n[\u0026#39;id\u0026#39; =\u0026gt; $id, \u0026#39;name\u0026#39; =\u0026gt; $name] = $user; foreach ($records as [\u0026#39;id\u0026#39; =\u0026gt; $id, \u0026#39;status\u0026#39; =\u0026gt; $status]) { // ... } Before this, extracting named keys from an array meant either extract() (which dumps everything into scope and invites collisions) or a bunch of individual assignments. This is just cleaner.\nThe iterable type If you write a function that accepts either an array or a generator, there was no clean type hint for that in 7.0. You either typed it as array and silently excluded generators, or dropped the hint entirely:\nfunction processItems(iterable $items): void { foreach ($items as $item) { $this-\u0026gt;handle($item); } } iterable accepts anything you can foreach over: arrays and Traversable implementations. It also works as a return type. Not dramatic, but it closes a real gap.\nNegative string offsets String indexing with [] or {} now accepts negative values, counting from the end:\n$str = \u0026#39;hello\u0026#39;; echo $str[-1]; // \u0026#34;o\u0026#34; echo $str[-2]; // \u0026#34;l\u0026#34; Several string functions got the same treatment: strpos(), substr(), substr_count(), and others now accept a negative offset. Consistent with how Python has worked forever. Better late than never.\nClosure::fromCallable() Before this, converting a callable (like [$object, 'method'] or a function name string) to a proper Closure required Closure::bind() or bindTo() with awkward scope handling. 7.1 adds a static factory method:\nclass Processor { private function transform(string $value): string { return strtoupper($value); } public function getTransformer(): Closure { return Closure::fromCallable([$this, \u0026#39;transform\u0026#39;]); } } The resulting closure captures the correct $this and scope. It\u0026rsquo;s particularly useful when passing methods as callbacks to functions that expect callable, or when building pipelines.\nArgumentCountError In PHP 7.0, calling a user-defined function with too few arguments generated a warning and execution continued with null-filled parameters. In 7.1, it throws an ArgumentCountError:\nfunction connect(string $host, int $port): void { /* ... */ } try { connect(\u0026#39;localhost\u0026#39;); // Throws ArgumentCountError } catch (\\ArgumentCountError $e) { // ... } ArgumentCountError extends TypeError, which extends Error. Call sites that previously silently degraded now fail loudly. That\u0026rsquo;s a migration risk if you have code that relied on the permissive behavior, but honestly, it\u0026rsquo;s the right call.\n7.1 is the kind of release that makes you trust a platform more. The core team was clearly paying attention to the friction, not just shipping headlines.\n","permalink":"https://guillaumedelre.github.io/2017/01/15/php-7.1-a-tighter-type-system-and-the-small-wins-around-it/","summary":"\u003cp\u003ePHP 7.1 shipped December 1st. No 2x performance headline, no engine rewrite. It fills in the gaps that 7.0 left in the type system, and those gaps were genuinely annoying.\u003c/p\u003e\n\u003ch2 id=\"nullable-types\"\u003eNullable types\u003c/h2\u003e\n\u003cp\u003e7.0 let you declare \u003ccode\u003estring $name\u003c/code\u003e as a parameter type. What it didn\u0026rsquo;t let you do was say \u0026ldquo;this can also be null\u0026rdquo;. You had to drop the type hint entirely or hack around it. 7.1 adds \u003ccode\u003e?\u003c/code\u003e prefix:\u003c/p\u003e","title":"PHP 7.1: a tighter type system and the small wins around it"},{"content":"PHP 7.0 dropped on December 3rd. A month and a half in, I\u0026rsquo;ve migrated two projects and the results are hard to ignore.\nThe headline number is 2x faster than PHP 5.6. That\u0026rsquo;s not a benchmark cherry-pick — it\u0026rsquo;s the median across real applications. The Zend Engine was rewritten to use a new internal value representation that cuts memory usage significantly and reduces allocations. On one project, average response time dropped by 40% with zero code changes. You just upgrade and it goes faster.\nBut performance isn\u0026rsquo;t the most interesting part.\nTypes, finally PHP has had type hints for objects since 5.0, for arrays since 5.1. In 7.0, you can finally declare scalar types for function parameters and return values:\nfunction add(int $a, int $b): int { return $a + $b; } In strict mode (declare(strict_types=1)), passing a float to that function throws a TypeError. In the default coercive mode, PHP converts the value. That distinction matters: strict mode is per-file, so you can adopt it gradually without nuking your whole codebase at once.\nReturn type declarations are the other half. Putting intent in the signature rather than a docblock means the engine enforces it, not a code reviewer who might be half-asleep.\nThe null coalescing operator ?? is small but used constantly:\n$username = $_GET[\u0026#39;user\u0026#39;] ?? \u0026#39;guest\u0026#39;; That replaces isset($_GET['user']) ? $_GET['user'] : 'guest'. It chains too: $a ?? $b ?? $c. After years of isset() noise, this alone was worth upgrading.\nThe breaking part The error handling overhaul is the real upgrade risk. Many fatal errors are now Error exceptions, catchable but different from Exception. Code that relied on fatal errors to halt execution silently now needs explicit handling. Legacy error suppression with @ also works differently in places.\nRead the migration guide before touching a production app. The payoff is real, but the gap between 5.6 and 7.0 is the widest PHP has ever had.\nThe spaceship operator \u0026lt;=\u0026gt; is a combined comparison operator that returns -1, 0, or 1. It\u0026rsquo;s mostly there for sorting:\nusort($users, function ($a, $b) { return $a-\u0026gt;age \u0026lt;=\u0026gt; $b-\u0026gt;age; }); Before this, a custom sort comparator was a small exercise in remembered arithmetic. $a - $b works for integers but silently breaks for floats. \u0026lt;=\u0026gt; does the right thing for every comparable type.\nAnonymous classes You can now instantiate a class defined inline, on the spot, without giving it a name:\n$logger = new class($config) implements LoggerInterface { public function __construct(private array $config) {} public function log(string $message): void { file_put_contents($this-\u0026gt;config[\u0026#39;path\u0026#39;], $message . PHP_EOL, FILE_APPEND); } }; The canonical use case is test doubles and one-off interface implementations that don\u0026rsquo;t deserve a file. It removes a real friction point: the gap between \u0026ldquo;I need an object\u0026rdquo; and \u0026ldquo;I have to create a class file for a 10-line thing\u0026rdquo;.\nCryptographically secure randomness PHP 5\u0026rsquo;s rand() and mt_rand() were never meant for security. 7.0 adds two functions that are:\n$token = bin2hex(random_bytes(32)); // 64-character hex token $pin = random_int(100000, 999999); random_bytes() pulls from the OS CSPRNG. random_int() wraps that for integers. These replace every home-grown token generation scheme that was quietly doing it wrong, which is most of them.\nGroup use declarations Before 7.0, importing five things from the same namespace meant five use statements. Now:\nuse App\\Model\\{User, Order, Product}; use function App\\Helpers\\{formatDate, slugify}; use const App\\Config\\{MAX_RETRIES, TIMEOUT}; Small ergonomic improvement, but it reduces the visual noise at the top of files with deep namespace hierarchies.\nGenerators grew up Generators in 5.5 were interesting but incomplete. 7.0 adds two things. First, a generator can now have a return value, accessible after iteration ends:\nfunction process(): Generator { yield \u0026#39;step 1\u0026#39;; yield \u0026#39;step 2\u0026#39;; return \u0026#39;done\u0026#39;; } $gen = process(); foreach ($gen as $step) { /* ... */ } echo $gen-\u0026gt;getReturn(); // \u0026#34;done\u0026#34; Second, yield from delegates to another generator or iterable, transparently passing values and return values through:\nfunction inner(): Generator { yield 1; yield 2; return \u0026#39;inner done\u0026#39;; } function outer(): Generator { $result = yield from inner(); echo $result; // \u0026#34;inner done\u0026#34; yield 3; } This makes composing generators practical without manually plumbing values between them.\nClosure::call() A more direct way to bind a closure to an object and call it immediately:\nclass Counter { private int $count = 0; } $increment = function (int $by): void { $this-\u0026gt;count += $by; }; $increment-\u0026gt;call(new Counter(), 5); bindTo() existed before but required two steps. call() collapses them and is faster at runtime because it skips the intermediate closure creation.\nUnicode escape syntax in strings You can now embed Unicode characters directly in double-quoted strings or heredocs using a codepoint:\necho \u0026#34;\\u{1F418}\u0026#34;; // 🐘 echo \u0026#34;\\u{00E9}\u0026#34;; // é Beats copy-pasting characters from a Unicode table into source files, which is what people were actually doing.\nSafer unserialize() unserialize() has a long history of being a vector for object injection attacks. 7.0 adds an allowed_classes option:\n$data = unserialize($input, [\u0026#39;allowed_classes\u0026#39; =\u0026gt; false]); $data = unserialize($input, [\u0026#39;allowed_classes\u0026#39; =\u0026gt; [User::class, Order::class]]); Passing false prevents any object from being instantiated during deserialization. This is the default you want when deserializing untrusted input.\n:1234: Integer division intdiv() is explicit integer division with no float intermediate:\n$pages = intdiv(count($items), $perPage); // int, no casting needed Yes, you could cast the result of a division. intdiv() makes the intent clear and avoids the float precision edge cases that casting introduces for large numbers.\nConstants as arrays Before 7.0, define() only accepted scalar values. Arrays worked with const at class or namespace scope but not with define(). Now they do:\ndefine(\u0026#39;HTTP_METHODS\u0026#39;, [\u0026#39;GET\u0026#39;, \u0026#39;POST\u0026#39;, \u0026#39;PUT\u0026#39;, \u0026#39;DELETE\u0026#39;, \u0026#39;PATCH\u0026#39;]); Useful for configuration that needs to be a constant but lives outside a class.\nAssertions with teeth assert() got a proper redesign. In PHP 5, assertions were a runtime eval of strings. Now they can throw exceptions and be completely removed in production with zero overhead:\n// In php.ini or at bootstrap: // assert.active = 1 (dev), 0 (prod) // assert.exception = 1 assert($user-\u0026gt;isVerified(), new \\LogicException(\u0026#39;Unverified user reached checkout\u0026#39;)); When assert.active = 0, the expression is never evaluated. When it\u0026rsquo;s on, a failing assertion throws the provided exception directly. This is finally a tool worth reaching for, without the embarrassment of admitting you used it.\nThe session_start() overhaul session_start() now accepts an array of options that override php.ini directives for that call:\nsession_start([ \u0026#39;cookie_lifetime\u0026#39; =\u0026gt; 86400, \u0026#39;cookie_secure\u0026#39; =\u0026gt; true, \u0026#39;cookie_httponly\u0026#39; =\u0026gt; true, \u0026#39;cookie_samesite\u0026#39; =\u0026gt; \u0026#39;Lax\u0026#39;, ]); Before this, you either set options globally in php.ini or called ini_set() before session_start(). Neither was great when you needed different session configurations in different parts of an app.\n","permalink":"https://guillaumedelre.github.io/2016/01/17/php-7.0-performance-types-and-the-features-that-stuck/","summary":"\u003cp\u003ePHP 7.0 dropped on December 3rd. A month and a half in, I\u0026rsquo;ve migrated two projects and the results are hard to ignore.\u003c/p\u003e\n\u003cp\u003eThe headline number is 2x faster than PHP 5.6. That\u0026rsquo;s not a benchmark cherry-pick — it\u0026rsquo;s the median across real applications. The Zend Engine was rewritten to use a new internal value representation that cuts memory usage significantly and reduces allocations. On one project, average response time dropped by 40% with zero code changes. You just upgrade and it goes faster.\u003c/p\u003e","title":"PHP 7.0: performance, types, and the features that stuck"}]