Introducing laravel-backup-restore

• 6 min read

Earlier this month I've tagged v1 on a new Laravel package. It's called laravel-backup-restore and – as the name might suggest – helps you restore a backup made with spatie/laravel-backup.

This post goes into detail what the package provides, how it came to be and how it can help you to make sure you always have healthy backups.

As mentioned, the package helps restore backups created with spatie/laravel-backup.

I use Spatie's package in a lot of my projects. It gives me the confidence that, in case of a fuck-up by me or a server issue, I have access to a somewhat up to date backup of my production database.

We also use Spatie's package at work. In one of those work projects, I once created a custom Restore-command, that would download the latest backup, decrypt and decompress the zip file and import the MySQL dump to my local database.

We primarily used this command, to help us debug production issues. Sometimes we needed a snapshot of the production database to debug certain parts of the app. Creating a new snapshot and downloading the backup always took quite a lot of time, due to the size of the database. Reusing the already existing daily backup was the obvious solution.

The now released package was inspired by my original Restore-command.

Fast forward to last December.

Our server provider informed us, that the operating system of a managed server needs to be updated; including upgrading to MySQL 8. "Sure, no problem" we said.
We've prepared our services and apps for a short downtime, created fresh backups for some of the apps hosted on that server and gave the server provider the go for update.

Half an hour later we were informed, that there's an issue with the MySQL upgrade and that downtime will take bit longer. The issue got resolved and MySQL was running again.

Before disabling the maintenance mode in our apps, we noticed that the database of one app was empty. Turns out that the MySQL backup made by the server provider failed for that database and we forgot to create a fresh backup for that specific app.
Luckily the server provider created a snapshot of the state of the server, before running the upgrade. They sent us a MySQL dump of that snapshot and we were back in business.

It wasn't the end of the world, but showed me once again, how important it is to check your backups regularly.

That's how this new package was born. I made it my goal to solve this "check backup integrity"-problem once and for all.

After you've installed laravel-backup-restore a new backup:restore-command is available in your Laravel project.

If you just run php artisan backup:restore, the command will become interactive and will ask for some input. Which backup to restore, the decryption password and a last confirmation to start the restore process.

asciicast

The restore process can also be automated by passing the --no-interaction option to the command. Laravel will then use any of the provided options or their default values for the restore process.

php artisan backup:restore --backup=latest --no-interaction

As explained earlier, the package will download the selected backup to your machine, decrypt and decompress it and then import the database dump into your local database. The package currently supports MySQL, PostgreSQL and SQLite.

File backups are not restored. The command would – for example – not replace your local storage/app directory with the folder stored in the backup.

The final feature I would like to highlight in this post are health checks. As explained earlier, my goal with this package is to solve the "backup integrity" problem I encountered at work.

To ensure that backups are okay, I needed a way to check if they are healthy. Health checks solve this problem by allowing users of the package to write their own "health check"-logic in simple PHP classes.

The package ships with a DatabaseHasTables health check to ensure, that there is at least one database table present after the backup has been restored.

Writing your own health check is super simple. Create a new class that extends Wnx\LaravelBackupRestore\HealthChecks\HealthCheck and implement the run-method.

The example below checks, that after the database has been restored, there exists at least one Sale-model that has been created yesterday.

namespace App\HealthChecks;

use Wnx\LaravelBackupRestore\PendingRestore;
use Wnx\LaravelBackupRestore\HealthChecks\HealthCheck;

class MyCustomHealthCheck extends HealthCheck
{
    public function run(PendingRestore $pendingRestore): Result
    {
        $result = Result::make($this);

        // We assume that your app generates sales every day.
        // This check ensures that the database contains sales from yesterday.
        $newSales = \App\Models\Sale::query()
            ->whereBetween('created_at', [
                now()->subDay()->startOfDay(), 
                now()->subDay()->endOfDay()
            ])
            ->exists();

        // If no sales were created yesterday, we consider the restore as failed.
        if ($newSales === false) {
            return $result->failed('Database contains no sales from yesterday.');
        }

        return $result->ok();
    }
}

This check would be useful, if you create daily backups and want to check the backup integrity on a daily basis too.
If you know with a 100% guarantee that new sales are made everyday, this is an easy way to check, that the backup contains the expected data.

But running the restore process manually is cumbersome. Why not automate it?

By using GitHub Action's schedule-trigger we can create a workflow that runs the backup:restore command in regular intervals.

The workflow below can be triggered manually or runs on the first day of each month automatically.

The restore command will restore a backup stored on AWS S3 and wipe the database, once the restore command has run through successfully. We pass some secret environment variables to the command, to ensure it can connect to AWS.

name: Validate Backup Integrity

on:
  # Allow triggering this workflow manually through the GitHub UI.
  workflow_dispatch:
  schedule:
    # Run workflow automatically on the first day of each month at 14:00 UTC
    # https://crontab.guru/#0_14_1_*_*
    - cron: "0 14 1 * *"

jobs:
  restore-backup:
    name: Restore backup
    runs-on: ubuntu-latest

    services:
      # Start MySQL and create an empty "laravel"-database
      mysql:
        image: mysql:latest
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: laravel
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 8.2

      - uses: ramsey/composer-install@v2

      - run: cp .env.example .env

      - run: php artisan key:generate

      # Download latest backup and restore it to the "laravel"-database.
      # By default the command checks, if the database contains any tables after the restore. 
      # You can write your own Health Checks to extend this feature.
      - name: Restore Backup
        run: php artisan backup:restore --backup=latest --no-interaction
        env:
            APP_NAME: 'Laravel'
            DB_PASSWORD: 'password'
            AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
            AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
            AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
            AWS_BACKUP_BUCKET: ${{ secrets.AWS_BACKUP_BUCKET }}
            BACKUP_ARCHIVE_PASSWORD: ${{ secrets.BACKUP_ARCHIVE_PASSWORD }}

      # Wipe database after the backup has been restored.
      - name: Wipe Database
        run: php artisan db:wipe --no-interaction
        env:
            DB_PASSWORD: 'password'

(More details on how this workflow works can be found in the GitHub repository.)

If the restore command – including any defined health checks – fail, the entire workflow will fail. GitHub will send a notification if this is the case or you could add additional steps to the workflow, to – for example – send you a Slack message if the restore failed.

Like many of my packages, I think v1 of this package is feature complete. I currently can't think of any new features I could add that would make the package better.

If you have any feedback or suggestions, please leave them on the GitHub repository.

And if you don't plan to use this package, I would encourage you to at least test your backups now and more regularly in the future.
Your future self will thank you.