# 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 short

- Brewless runs the release commands, migrate --force by default, on the new image before the web container switches to it.
- When a release command fails, the release does not go live and the release that was serving visitors keeps doing so.
- The release commands get 90 seconds per release together, so long data changes belong in a queued job, not in a migration.
- Between the migrations and the switch, the old code runs against the new schema, so every migration has to work with the code that is live before it.
- A rollback puts back the image of an earlier release but never migrates the database back.

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.

```bash
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:

```bash
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.

```bash
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.

```php 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:

```yaml 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](https://docs.brewless.eu/deployments/deployments), and what a rollback does and does not undo is in [Rollbacks](https://docs.brewless.eu/deployments/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.

Source: https://test.brewless.eu/blog/laravel-migrations-as-a-release-step
