# Rollbacks in Laravel: what you can and cannot undo

A rollback brings back your Laravel code and variables, not your database. What brewless rollback undoes, and how to keep migrations safe to roll back.

## In short

- A rollback puts earlier code and configuration live again, but it never moves your database back in time.
- After a rollback, the older code runs against the newer database schema, so every migration has to leave a schema the previous release can still work with.
- Destructive changes, such as dropping a column, belong in a later release than the code that stops using it.
- Only a backup brings back data, and a backup you have never restored is a hope, not a plan.

Every deploy tool has a rollback button, and every team that has used one in anger has learned the same thing: it brings back the code, not the data. Press it after a migration dropped a column, and you are running old code against a table that no longer has what it expects.

This post goes through what a rollback can and cannot undo in a Laravel app, how `brewless rollback` works, and how to write migrations so that rolling back stays a safe option.

## What can a Laravel rollback undo?

A rollback undoes the code, the configuration and the environment variables of a release by putting an earlier release live again. It does not undo anything in your database, your files or the outside services your app called. Those only come back through a fix forward or a backup.

| Part | Rolled back? | Why |
| --- | --- | --- |
| Application code | Yes | The earlier release's image runs again |
| PHP version | Yes | It runs on the version it was built for |
| Environment variables | Yes | The revision the earlier release ran with |
| Container sizes, scale, queues, scheduler | Yes | What `brewless.yml` asked of that release |
| Database schema | No | Nothing is migrated back |
| Data in the database | No | Rows written or deleted stay that way |
| Files in your bucket | No | Uploads and deletions stay |
| Jobs on the queue | No | A job queued by the newer code runs on the older code |
| Mail sent, payments made | No | They happened |

## How brewless rollback works

```bash
brewless rollback production
```

Without a number, this goes back to the release before the live one. To pick one, list the releases first:

```bash
brewless releases production
brewless rollback production --release=182
```

The client asks for confirmation; `--force` skips the question. The image of the earlier release then runs again as a new release. Nothing is rebuilt, so it is quick, and the release history stays a straight line: the rollback is its newest entry.

Two limits:

- You can only go back to a release that served visitors at some point. A release that failed or was cancelled never had an image that is known to work.
- A rollback does not touch the containers of your [services](https://docs.brewless.eu/running/services). It does take back which services the application is told about.

The earlier release's release commands run again before it goes live. For a Laravel app that is `migrate --force` by default, which finds nothing new to run: the older code has fewer migrations than the database has already seen.

The full reference is on [Rollbacks](https://docs.brewless.eu/deployments/rollbacks).

## Variables come along

A rollback takes the revision of your variables that the earlier release ran with. That is useful: if a release went wrong because of a changed variable, such as a new mail driver, a rollback undoes both together.

It also means a variable you changed since the earlier release is not live after the rollback. The newer revision stays in your Secret Manager, and the next deploy takes the newest one again. If the reason for the rollback was the variable, change it before you deploy again. [Laravel secrets without a .env file](/blog/laravel-secrets-without-env-file) explains how revisions work.

## The database stays where it is

> [!WARNING]
> After a rollback, the older code runs against the newer schema. If a later release renamed or dropped a column, the older code will ask for something that is not there.

The console tells you before you confirm when later releases changed the database. That is the moment to decide whether a rollback helps, or whether a fix forward is the better way out.

It is tempting to follow a rollback with `php artisan migrate:rollback`. Do not count on it:

- A `down()` method that recreates a dropped column brings back an empty column, not the data that was in it.
- Many `down()` methods were never run, and fail on a production database with real rows.
- The `brewless/laravel` package refuses commands that throw data away, such as `migrate:rollback`, `migrate:fresh` and `db:wipe`, as release commands and when run through Brewless. Taking data away is a step someone takes on purpose, not a side effect of a deploy.

The safe way out of a bad migration is forward: a new migration that repairs the schema, in a new release.

## Write migrations a rollback can survive

The rule that keeps rollbacks safe: **every migration leaves a schema that the previous release can still work with.** In practice that means splitting a breaking change over more than one release, often called expand and contract.

Renaming `users.name` to `users.full_name`, for example:

| Release | Migration | Code |
| --- | --- | --- |
| 1 | Add `full_name`, nullable | Writes both columns, reads `name` |
| 2 | Copy `name` into `full_name` where empty | Reads `full_name` |
| 3 | Drop `name` | Only knows `full_name` |

Release 2 can roll back to release 1 without trouble: both columns exist. Release 3 is the only one with a destructive step, and by then nothing has read `name` for a whole release.

```php database/migrations/2026_10_07_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()->after('name');
        });
    }

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

The same thinking applies to:

- **New `NOT NULL` columns.** Add them nullable or with a default, so the older code can still insert rows.
- **Dropped tables.** Stop using the table in one release, drop it in a later one.
- **Queued jobs.** A job class the newer code queued and the older code does not know fails after a rollback. Keep job classes and their constructor arguments compatible for one release.
- **Enum values.** Older code may not know a value the newer code wrote. Handle unknown values instead of throwing.

> [!TIP]
> Make the destructive release small: just the migration that drops, with no other change. If something goes wrong, you know where to look.

## Backups bring back data

When data is gone, only a backup brings it back. A rollback cannot, and neither can a `down()` method.

Brewless can give an environment a bucket for backups, in a region of its own:

```yaml brewless.yml
environments:
  production:
    backup:
      region: nl-ams
      expire_days: 35
```

Your app gets a key that may put a backup there and nothing more: it cannot read, list or remove one. Object Storage removes each backup `expire_days` after it was put, so your backup job never needs to delete anything. A second key, for a restore, is kept in your Secret Manager and never goes into the app's variables. So a bug or a leaked app key cannot wipe your backups. See [A bucket for backups](https://docs.brewless.eu/applications/applications-and-environments#a-bucket-for-backups).

Brewless makes the bucket and the keys; the backups themselves come from your app, such as a scheduled job that dumps the database and puts the file there with the `BACKUP_*` variables. Check also what your database at Scaleway keeps of its own, and how far back that goes.

Then restore one. Pick a quiet moment, read the restore key from Secret Manager, load the latest backup into a fresh database, and check that the rows you expect are there. The first restore is the one that finds the missing table.

## Before you press rollback

1. Check what the release you go back to expects of the database. The console warns when later releases changed it.
2. Check which variables change with it.
3. Roll back with `brewless rollback production`, or pick a release with `--release=`.
4. Fix forward with a new release; never migrate data away to make old code fit.
5. If data is gone, restore from a backup, not from a `down()` method.

A rollback is a quick way back for code. Kept that way, with migrations that never break the previous release, it is the safest button in your deploy tool. Set up your pipeline to deploy the fix after it, as in [deploying Laravel from CI](/blog/deploy-laravel-from-ci).

## Frequently asked questions

### Does brewless rollback undo my Laravel migrations?

No. A rollback puts the image of an earlier release live again and leaves the database as it is. If later releases changed the schema, the older code has to work with that newer schema. The console warns you before you confirm.

### Why not run php artisan migrate:rollback after a rollback?

The down() method of a migration that dropped a column can recreate the column, but not the data that was in it. The brewless/laravel package also refuses commands that throw data away, such as migrate:rollback, as release commands. Fix forward with a new migration instead.

### Which variables does a rollback use?

The revision of your variables that the earlier release ran with. Variables you changed since stay in Secret Manager as newer revisions, and the next deploy takes the newest one again.

### How fast is a rollback with Brewless?

Fast, because nothing is rebuilt. The image of the earlier release runs again as a new release, after its release commands run. You can only go back to a release that served visitors at some point.

### How do I get deleted data back?

From a backup. Brewless can make a bucket for backups that your app may write to but not read or delete from, with a lifecycle rule that removes old backups. Making the backups and testing a restore are up to you.

Source: https://test.brewless.eu/blog/laravel-rollbacks
