Laravel migrations as a release step

Brewless runs Laravel migrations before a release goes live, and a failing one keeps the old release serving. How to write migrations that are safe for that.

In this post
  1. When do Laravel migrations run on Brewless?
  2. Where the step sits in a deploy
  3. A failing migration keeps the old release live
  4. 90 seconds per release
  5. The window between migrate and go live
  6. Expand, then contract
  7. Choosing the release commands
  8. Summary

On a classic server, deploying Laravel often means SSH, git pull, php artisan migrate --force and hoping nothing breaks in between. On Brewless, migrations are a step of the release itself, with a clear rule: the new code only goes live when its migrations succeeded.

That rule gives you a safety net, and it puts one demand on how you write migrations. This tutorial covers both.

When do Laravel migrations run on Brewless?

On every deploy, after the image is built and before the new release goes live. Brewless runs the release commands, migrate --force by default, on a container with the new image. If they succeed, the web container switches to the new image; if one fails, nothing changes for visitors.

Where the step sits in a deploy

A deploy runs through these steps, and the console and brewless deploy show each one:

  1. Pack the current commit.
  2. Upload it into a private bucket of your own Scaleway project.
  3. Build the image, in a job in your project.
  4. Migrate: run the release commands on the new image.
  5. Go live: switch the web container to the new image, check its health route, then move the workers.

The release commands run on the release container, a third container of the environment next to web and worker. Brewless calls a route of the brewless/laravel package on it with a secret of its own, and the package runs the commands in your application, with its own database connection. You never run migrations by hand in a container, and the start script should not run them either: it runs on every cold start of every container.

A failing migration keeps the old release live

When a release command fails, the release gets the status Failed and never goes live. The web container still runs the previous image, and visitors notice nothing.

brewless deploy production

The client says the release commands failed and that the previous release keeps serving. Why they failed is in your application's own log, not at Brewless, because an error can quote the data it failed on:

brewless logs production --source=release

Fix the migration, commit, and deploy again.

Warning

A failed release can still have changed the database. Migrations that ran before the one that failed stay applied. On PostgreSQL, Laravel runs each migration in a transaction, so the one that failed leaves nothing half done; on MySQL a schema change cannot be rolled back, and a migration can stop halfway. Check with brewless command production "migrate:status".

90 seconds per release

The release commands get 90 seconds per release, all of them together. That holds whatever timeout you give the release container in brewless.yml.

Ninety seconds is plenty for schema changes: adding a table, a column or an index on a table of normal size. It is not enough for rewriting millions of rows. Keep the two apart:

  • Schema changes go in migrations.
  • Data changes on large tables go in a queued job or a command, dispatched or run once the release is live.
brewless command production "orders:backfill-totals"

A one-off command gets 50 seconds. Work that takes longer belongs in a queued job, which gets the worker's timeout instead.

The window between migrate and go live

This is the part that shapes how you write migrations. The steps above mean that for a moment, and sometimes longer, the database already has the new schema while the old code still serves visitors:

Moment Database schema Web container Workers
Before the deploy Old Old code Old code
After the migrate step New Old code Old code
After go live New New code Old code, then new
After a rollback New Old code Old code

Three cases put old code on the new schema:

  1. During the deploy. Between the migrate step and the switch, the old release keeps serving.
  2. When going live fails. The migrations succeeded, but the new container does not start or does not answer healthy. The previous release keeps serving, now on the new schema.
  3. After a rollback. A rollback puts the image of an earlier release live again, and runs its release commands, but nothing is migrated back. The console tells you before you confirm when later releases changed the database.

So the rule for every migration: the code that is live before it must keep working after it.

Expand, then contract

The way to follow that rule is to split a breaking change over two or more deploys. First you expand the schema so old and new code both work, then, once nothing uses the old shape any more, you contract it.

Renaming users.name to users.full_name in one migration breaks the old code the moment the migration ran. Split it:

Deploy 1: expand. Add the new column, and have the new code write both.

database/migrations/2026_09_13_000001_add_full_name_to_users.php
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->string('full_name')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->dropColumn('full_name');
        });
    }
};

Between the deploys: backfill. Copy name into full_name for existing rows, in a queued job or a command, so it is not bound to the 90 seconds.

Deploy 2: switch reads. The code reads full_name only. The old column is still there, so a rollback to deploy 1 still works.

Deploy 3: contract. Drop name. By now no release you would roll back to reads it.

The same pattern covers most changes:

Change Safe in one deploy Split it
Add a table Yes
Add a nullable column, or one with a default Yes
Add an index Yes, on a table of normal size Large tables: mind the 90 seconds
Add a NOT NULL column without a default No Add nullable, backfill, then add the constraint
Rename a column or table No Add new, write both, switch reads, drop old
Drop a column Only when the live code no longer uses it Stop using it first, drop it a deploy later
Change a column's type No New column, backfill, switch, drop

Tip

Dropping a column the model still lists in $fillable or $casts, or that a query selects by name, breaks the old code. Remove those references in one deploy and the column in the next.

Choosing the release commands

By default the release commands are migrate --force. A release list in brewless.yml replaces them, and runs in the order you give:

brewless.yml
organisation: acme
application: shop
framework: laravel
release:
  - migrate --force
  - cache:clear

A few rules apply:

  • The list needs brewless/laravel 0.3 or later. With an older version the application runs its own commands, and the release does not go live, with a message that says so.
  • Commands run on every deploy, and again on a rollback, so each one has to be safe to run twice. migrate --force is: it only runs what has not run yet.
  • Commands that never end, such as queue:work, schedule:work or tinker, are refused.
  • Commands that throw data away are refused: db:wipe, migrate:fresh, migrate:refresh, migrate:reset and migrate:rollback. That is a step someone takes on purpose, not a side effect of a deploy.

The list goes along with each release, so a rollback runs the commands that release was deployed with.

Summary

  1. Migrations run before the release goes live; a failure keeps the old release serving.
  2. All release commands together get 90 seconds. Move large data changes into jobs.
  3. The old code runs on the new schema during a deploy, after a failed switch and after a rollback. Write every migration so the live code keeps working.
  4. Split breaking changes: expand, backfill, switch, contract.
  5. Set your own release list when you need more than migrate --force.

The steps of a deploy are in Deployments, and what a rollback does and does not undo is in Rollbacks.

Frequently asked questions

When does Brewless run my Laravel migrations?

On every deploy, after the image is built and before the web container switches to it. The release commands run on a release container with the new image, and only when they succeed does the release go live. By default the only release command is migrate --force.

What happens when a migration fails on Brewless?

The release fails and never goes live. The release that was serving visitors keeps doing so, and the console shows that the migrate step failed. Why it failed is in the log of the release container, which brewless logs production --source=release shows.

How long may my migrations take on Brewless?

The release commands get 90 seconds per release, together, whatever timeout the release container has. Keep schema changes in migrations and move large data changes into a queued job that runs after the release is live.

Does a rollback undo my migrations?

No. A rollback runs the image of an earlier release again, as a new release, and leaves the database as it is. Its release commands run again, but nothing is migrated back, so the earlier code has to work with the newer schema.

Can I run other commands than migrate on a release?

Yes. A release list in brewless.yml replaces the default and runs in the order you give, before the release goes live. Commands that never end, such as queue:work, and commands that throw data away, such as migrate:fresh or migrate:rollback, are refused.

All posts Read this post as markdown

Try it on your own app

Brewless deploys your Laravel app to Scaleway and bunny.net, in your own accounts. We open access to Laravel teams in small batches.