Restore database backups made with spatie/laravel-backup

Earlier this month, I released version 1.0 of a new Laravel package named laravel-backup-restore. As the name implies, this package is designed to assist you in restoring backups created using spatie/laravel-backup.

In this post, I will delve into the details of what this package offers, its origins, and how it can help ensure the reliability of your backup strategy.

The Problem and Its Solution

Problem Statement

This package addresses the need to restore backups generated with spatie/laravel-backup.

I frequently use Spatie's package in my projects. It provides me with the peace of mind that, in the event of a mistake on my part or a server issue, I have access to a reasonably up-to-date backup of my production database.

At my workplace, we also rely on Spatie's package. In one particular project, I once created a custom Restore command. This command would download the latest backup, decrypt and decompress the zip file, and then import the MySQL dump into my local database.

We primarily used this command for debugging production issues. Sometimes, we needed a snapshot of the production database to troubleshoot specific parts of the application. Generating a new snapshot and downloading the backup took a significant amount of time due to the database's size. Reusing the existing daily backup seemed like the logical solution.

The idea for the package I am releasing now was inspired by my original Restore command.

The Importance of Healthy Backups

Fast forward to last December.

Our server provider informed us that the managed server's operating system needed an update, including an upgrade to MySQL 8. We agreed and prepared our services and apps for a short downtime. We created fresh backups for some of the apps hosted on that server and gave the green light for the update.

Thirty minutes later, we were informed of an issue with the MySQL upgrade, and the downtime would be extended. The problem was eventually resolved, and MySQL was up and running again.

Before disabling the maintenance mode in our apps, we noticed that the database of one app was empty. It turned out that the MySQL backup made by the server provider had failed for that specific database, and we had forgotten to create a fresh backup for that app.

Fortunately, the server provider had taken a snapshot of the server's state before the update. They provided us with a MySQL dump of that snapshot, and we were back in business.

While it wasn't a catastrophic event, it served as a reminder of how crucial it is to regularly validate the integrity of your backups.

This package was born out of the desire to address this "check backup integrity" problem once and for all.

What the Package Offers

Once you've installed it, a new backup:restore command becomes available in your Laravel project.

If you run php artisan backup:restore, the command will turn interactive, prompting you for specific details: which backup to restore, the decryption password, and final confirmation to initiate the restoration process.

You can also automate the restore process by adding the --no-interaction option to the command. Laravel will then use the provided options or their default values for the restore process.

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

As mentioned 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.

Please note that file backups are not restored. For example, the command will not replace your local storage/app directory with the folder stored in the backup.

Health Checks

The final feature I'd like to highlight in this post is the health checks. As previously mentioned, the primary goal of this package is to ensure the integrity of backups.

I needed a way to check their health to verify that backups were in good shape. Health checks address this issue by allowing package users to define their own "health check" logic using straightforward PHP classes.

The package includes a DatabaseHasTables health check to ensure that at least one database table exists after the backup has been restored.

Creating your health check is a straightforward process. You need to create a new class that extends Wnx\LaravelBackupRestore\HealthChecks\HealthCheck and implement the run method.

Here's an example of a custom health check that ensures there is at least one Sale model created yesterday after the database has been restored:

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 is useful if you create daily backups and want to verify the backup's integrity daily. If you're certain that new sales are generated every day, this is a straightforward way to check if the backup contains the expected data.

Automating Backup Integrity Checks with GitHub Actions

You can use GitHub Actions to automate the verification of backup integrity. By using a schedule trigger, you can create a workflow that runs the backup:restore command at regular intervals.

Below is a sample workflow that can be triggered manually or runs automatically on the first day of each month:

name: Validate Backup Integrity

on:
  workflow_dispatch:
  schedule:
    - cron: "0 14 1 * *"

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

    services:
      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

      - 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 }}

      - name: Wipe Database
        run: php artisan db:wipe --no-interaction
        env:
            DB_PASSWORD: 'password'

If the restore command, along with any defined health checks, fails, the entire workflow will fail. GitHub will send a notification in case of failure, or you can include additional steps to send alerts through platforms like Slack.

Outlook

As with many of my packages, I consider version 1.0 of this package to be feature-complete. At present, I can't think of any additional features that would enhance the package.

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

Even if you don't plan to use this package, I strongly encourage you to test your backups now and in the future. Your future self will thank you.

For more details, visit the GitHub repository.