The Pre-Deploy Checklist
Five checks to run before a release, how to read a migration before it runs, and a rollback you have actually performed once.
php bin/console lint:container && php bin/console doctrine:schema:validateMost deploys that fail in a surprising way could have been stopped by five commands and two minutes of reading. Run them in continuous integration against the exact commit you are about to ship, and make a failure stop the pipeline.
The checks
vendor/bin/requirements-checker— the PHP version and extensions the project needs are present on the target.php bin/console lint:container— every service's constructor arguments can actually be satisfied. A wiring mistake otherwise shows up on the first request instead.php bin/console doctrine:schema:validate— the entity mapping and the database agree. A column that was never added is a 500 on one page, not a startup failure.php bin/console secrets:list— the values the new code reads exist, in the vault or in the environment.php bin/console doctrine:migrations:migrate --dry-run— read the SQL. Look for anything that rewrites or locks a large table.
Rollback
Redeploying the previous image is easy. The schema is what traps you, because a migration that dropped a column cannot be undone by starting an older container. So make schema changes additive first: add the column, deploy code that writes both, remove the old column in a later release. Then every rollback is only the image.
Perform that rollback once, deliberately, on a day when nothing is wrong. An untested rollback is a plan, not a capability.
Example
// tests/Smoke/ReleaseSmokeTest.php
declare(strict_types=1);
namespace App\Tests\Smoke;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
// The last item on the checklist: the few requests that must work, run
// against the built application before it is allowed to take traffic.
final class ReleaseSmokeTest extends WebTestCase
{
public function testHomePageAnswers(): void
{
$client = static::createClient();
$client->request('GET', '/');
// Shorthand for "status code is in the 2xx range".
self::assertResponseIsSuccessful();
}
public function testReadinessReportsTheDatabase(): void
{
$client = static::createClient();
$client->request('GET', '/health/ready');
// The longhand, when you need one exact code rather than any 2xx:
// self::assertSame(200, $client->getResponse()->getStatusCode());
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertJsonStringEqualsJsonString(
'{"status":"ok","database":"reachable"}',
(string) $client->getResponse()->getContent(),
);
}
}When to use it
- Turn a deploy checklist into a CI job that blocks the release when a check fails.
- Read a pending migration for table locks before it runs against production data.
- Catch a missing secret or environment variable before the first request needs it.
- Rehearse a rollback so the real one is a known procedure rather than an experiment.
More examples
The checks, in the order they are worth running
Put these in one CI job and keep the order: a failed requirements check makes every later result meaningless. Only the last two need credentials for the target, which is why they are last. doctrine:schema:validate reports the mapping and the database separately, and the two failures mean different things: a mapping error is a bug in your attributes, while an out-of-sync database usually means a migration has not been run.
# cheapest first: nothing below runs if this fails
vendor/bin/requirements-checker
# service wiring, resolved at compile time rather than on the first request
php bin/console lint:container
# configuration files, before the cache build turns a typo into a stack trace
php bin/console lint:yaml config/
php bin/console lint:twig templates/
# mapping against the live schema; reports the mapping and the database apart
APP_ENV=prod php bin/console doctrine:schema:validate
# are the values this release reads actually present
APP_ENV=prod php bin/console secrets:listReading a migration before it runs
Read the dry run for three things: a table rewrite on a large table, a column being dropped, and a NOT NULL column added without a default. The first takes the table out of service for the duration, and the other two are what make a rollback impossible. If any of them appear, split the change across two releases rather than hoping the window is short enough.
# print the SQL without executing anything
APP_ENV=prod php bin/console doctrine:migrations:migrate --dry-run
# or keep it as a file to review and to attach to the change record
APP_ENV=prod php bin/console doctrine:migrations:migrate \
--write-sql=var/release.sql
# apply it, unattended, during the release
APP_ENV=prod php bin/console doctrine:migrations:migrate --no-interactionMaking the requirements check part of the build
Registered as an auto-script, the checker runs as part of composer install, so an incompatible PHP version or a missing extension fails the build instead of the deploy. It belongs in require-dev: it is a build-time tool, and a --no-dev install on the target will not ship it. Run the build on the same PHP version and base image as production, or the check is only telling you about the build machine.
{
"require-dev": {
"symfony/requirements-checker": "^2.0"
},
"scripts": {
"auto-scripts": {
"vendor/bin/requirements-checker": "php-script"
}
}
}
Discussion