Search by

softartisan / laravel-vanguard

softartisan

A powerful, multi-tenant aware backup manager for Laravel with stancl/tenancy v3 support — with a beautiful dashboard.

Package info

github.com/softartisan-inc/laravel-vanguard

pkg:composer/softartisan/laravel-vanguard

Statistics

Installs: 14

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.7.0 2026-10-22 12:46 UTC

README

A multi-tenant backup dashboard for Laravel, built with Vue 3 + Vite and real-time updates via Server-Sent Events.

Requirements

Supported
PHP ^8.3 (CI runs 8.3, 8.4, 8.5)
Laravel ^12.0 only
System binaries tar, gzip (GNU tar); mysqldump/mysql or pg_dump/psql for database backups
Optional age for encrypted archives; league/flysystem-aws-s3-v3 for an S3 or Cloudflare R2 remote

PHP 8.1/8.2 and Laravel 10/11 were dropped in 2.0 — see Upgrading from 1.x to 2.0.

Since 2.7, a few modes are available on demand — all off by default, so an existing installation behaves exactly as before until it sets them: single database under Row-Level Security, dedicated backup/restore connections, pg_dump version check, encrypted archives and Cloudflare R2. See Upgrading to 2.7 before upgrading an application that extends Vanguard.

Without the mysqldump binary, MySQL dumps fall back to a pure-PDO implementation (slower, but no binary needed). Binary locations can be pinned in vanguard.binaries when they are not in the PATH of your web/queue process.

Upgrading to 2.7

Nothing to do for an application that only uses Vanguard: the defaults are unchanged, there is no migration, and archives taken with earlier 2.x versions restore as before.

1. No need to republish the config. Every new key falls back to a default that keeps the 2.6 behaviour. php artisan vanguard:install lists them under New optional settings (disabled by default, nothing to do); only keys whose absence can change behaviour are reported as missing. To opt in, note that Laravel merges the package config one level deep only: the new database, encryption and r2 sections are read from their environment variables as is, but a key nested in a section your published config already has — tenancy.database_mode, dump.pgsql_version_check, binaries.age — must be copied into your config/vanguard.php first, or its environment variable is never read. vanguard:install flags those keys.

2. A subclass that redefines BackupManager::createRecord(). It gained a fourth, optional parameter, ?array $sources = null (what the backup holds; null for the configured sources). PHP requires an overriding method to accept it, so add it to your signature and pass it on:

protected function createRecord(?string $tenantId, string $type, array $meta = [], ?array $sources = null): BackupRecord
{
    // ...
    return parent::createRecord($tenantId, $type, $meta, $sources);
}

A subclass that only calls createRecord() has nothing to change.

3. A subclass that writes its own backups. When it dumps and archives on its own instead of going through backupLandlord() / backupTenant() / backupFilesystem(), call $this->assertEncryptionReady() before dumping or archiving to honour the opt-in encryption the same way: with encryption on and no usable recipient or age binary, the backup is refused before anything is dumped. Without that call the same error is raised later, when the archive is bundled — never a plaintext archive, but only once the dump has run.

composer require softartisan/laravel-vanguard:^2.7

Upgrading from 1.x to 2.0

Four things to check before upgrading.

1. PHP and Laravel. 2.0 requires PHP ^8.3 and Laravel ^12.0. Laravel 11 was dropped deliberately: every 11.x release currently carries open security advisories, and Composer refuses to resolve it without waiving them. If you are on Laravel 10 or 11, upgrade the framework first — stay on Vanguard 1.x until then.

2. A backup that always "succeeded" may now start failing — loudly, and correctly. Up to 1.x the MySQL dump ran as a shell pipeline (mysqldump … 2>&1 | gzip > file), so the exit code Vanguard checked was gzip's, not mysqldump's. A dump that failed halfway — missing privilege, dead connection, full disk — was still recorded as a completed backup, and mysqldump's error message was written inside the .sql.gz. 2.0 runs mysqldump directly (no shell), keeps stderr on a separate pipe, checks mysqldump's own exit code, and verifies every byte reaches the disk; a failed or unwritable dump now throws and the partial file is deleted. If a nightly backup starts failing right after the upgrade, it was very probably already broken — read the error, do not silence it. Restoring one of your existing .sql.gz files into a scratch database is the fastest way to find out how long it has been broken.

3. The filesystem archive layout changed (and filesystem restore now actually works). 1.x archives were created from absolute paths, so tar stored members like var/www/html/storage/app/…; extracting them into storage/ recreated that whole tree under storage/ instead of putting the files back. Archives are now created relative to storage_path(), so storage/app/public/x.png is stored as ./app/public/x.png and lands back exactly where it came from. Archives taken with 1.x remain restorable: the restore lists the archive's members and works the legacy prefix out of their shape — members announced as ./… mean a current archive and nothing is stripped; otherwise the leading chain common to every member is cut at the component named like the destination (storage), falling back to the depth at which the configured backup roots appear. The prefix is then dropped with --strip-components. It is never read from the producing machine's path, so a bundle made in production restores on staging just as well, and old and new archives can live side by side.

4. New config key. vanguard.dump.mysql_options (see MySQL dump options). Publishing the config again is optional — a published 1.x config with no dump section falls back to the same defaults in code.

composer require softartisan/laravel-vanguard:^2.0
php artisan vendor:publish --tag=vanguard-config --force   # optional, to pick up the new section

Installation

composer require softartisan/laravel-vanguard

1. Publish config & run migrations

php artisan vendor:publish --tag=vanguard-config
php artisan vendor:publish --tag=vanguard-migrations
php artisan migrate

2. Build frontend assets

cd vendor/softartisan/laravel-vanguard
npm install
npm run build
cd -
php artisan vendor:publish --tag=vanguard-assets

Local development with hot-reload:

cd vendor/softartisan/laravel-vanguard && npm run watch

On deploy: re-run npm run build + vendor:publish --tag=vanguard-assets only when the package version changes.

Configuration — config/vanguard.php

'path' => env('VANGUARD_PATH', 'vanguard'),   // yourapp.com/vanguard

'realtime' => [
    'driver'       => env('VANGUARD_REALTIME_DRIVER', 'sse'),  // 'sse' | 'polling'
    'interval'     => env('VANGUARD_POLL_INTERVAL', 5),        // seconds (polling only)
    'sse_interval' => env('VANGUARD_SSE_INTERVAL', 2),         // DB check interval (SSE)
    'max_lifetime' => env('VANGUARD_SSE_LIFETIME', 120),       // auto-reconnect after Ns
],

Real-time drivers

Driver Mechanism Best for
sse (default) One persistent HTTP connection; server pushes only on state change Most setups — zero overhead at idle
polling API fetch every N seconds Proxies/hosts that block streaming

Nginx: add proxy_buffering off; to your location block for SSE.

MySQL dump options

Options handed to mysqldump on every MySQL/MariaDB dump:

'dump' => [
    'mysql_options' => env(
        'VANGUARD_MYSQL_DUMP_OPTIONS',
        '--single-transaction --quick --routines --triggers --no-tablespaces',
    ),
],

A space-separated string or an array of options — both are accepted. The value replaces the defaults, it is not merged with them, so repeat the ones you want to keep.

Default option Why
--single-transaction Consistent snapshot without locking the tables — the application keeps writing during the dump (InnoDB)
--quick mysqldump streams rows instead of buffering a whole table in memory
--routines --triggers Stored procedures, functions and triggers are now part of the dump — restoring a backup restores them too
--no-tablespaces Avoids the PROCESS privilege MySQL 8 would otherwise demand

--events is deliberately not a default: it requires the EVENT privilege, which managed/restricted accounts often lack, and its absence would make the whole dump fail. Add it if your account has it:

VANGUARD_MYSQL_DUMP_OPTIONS="--single-transaction --quick --routines --triggers --events --no-tablespaces"

These options apply to the mysqldump binary only. When the binary is missing, the PDO fallback takes over: it reads every table with buffering off and writes batched multi-row INSERTs, so a large table is never loaded into PHP memory.

An archive that captured no file

sources.filesystem_paths is relative to storage_path(), and under stancl/tenancy storage_path() is the tenant's own root for the duration of its backup. A tenant whose root has no app/ directory therefore matched none of the configured paths, and the backup produced a valid, tiny, empty tarball while the command printed ✅ Completed.

That case is now named — in the log, on the console, and on the backup record (meta.filesystem_empty, which the API returns as filesystem_empty) so a list of green rows cannot hide it:

# warn (default): complete the backup, but say the archive carries no file
# fail: refuse the backup instead
VANGUARD_ON_EMPTY_FILESYSTEM=warn

The default stays warn because an installation that genuinely keeps nothing under storage/app is legitimate. Set fail where an empty filesystem archive can only mean a misconfiguration.

The restore side says it too: extracting a filesystem member that holds no file warns instead of reporting a successful restore of nothing, and --wipe-storage is refused in that case — erasing your directories to replace them with an empty archive is not a restore.

Notifications

Vanguard tells you when a backup fails. Until 2.0.1 this config existed and nothing read it, so an installation could believe it was protected while every run failed — set the address and check that a test failure reaches you.

'notifications' => [
    'on_failure' => env('VANGUARD_NOTIFY_FAILURE', true),
    'on_success' => env('VANGUARD_NOTIFY_SUCCESS', false),
    'mail'  => ['enabled' => true,  'to' => env('VANGUARD_NOTIFY_MAIL')],
    'slack' => ['enabled' => false, 'webhook_url' => env('VANGUARD_SLACK_WEBHOOK')],
],
Setting Effect
on_failure Notify when a backup fails. On by default — leave it on.
on_success Notify on every successful backup too. Off by default; useful while you are still proving a new destination works.
mail.to Address the notification is sent to. Empty means no alert at all.
slack.webhook_url Incoming-webhook URL, posted to directly — no extra channel package required.

A channel that fails (mail server down, webhook unreachable) is logged and never turns a working backup into a failed one.

Authentication

Authorization stays in the application: Vanguard ships no roles and no policy of its own. The default only checks that someone is logged in ($request->user() !== null) — on an application where every customer has an account, every customer would reach the dashboard, its downloads and its restore button. Always set the callback:

// AppServiceProvider::boot()
use SoftArtisan\Vanguard\Facades\Vanguard;

Vanguard::auth(fn (Request $r) => $r->user()?->isAdmin());

With a gate, so the rule lives beside the application's other abilities:

// AppServiceProvider::boot()
use Illuminate\Support\Facades\Gate;
use SoftArtisan\Vanguard\Facades\Vanguard;

Gate::define('viewVanguard', fn ($user) => $user->hasRole('platform-admin'));

Vanguard::auth(fn (Request $r) => $r->user() !== null && Gate::forUser($r->user())->allows('viewVanguard'));
  • The callback guards every Vanguard route — the dashboard, the JSON API (run, restore, delete, download), the health screen and the SSE stream — except the two static assets (vanguard.js, vanguard.css).
  • It runs after the vanguard.middleware stack (['web'] by default). If the dashboard authenticates on another guard, add its middleware there (e.g. ['web', 'auth:ops']) and read $r->user('ops') in the callback.
  • In a multi-tenant application, serve the dashboard from the central domain only (VANGUARD_DOMAIN) and grant it to platform operators, never to a tenant's own administrators: it lists and restores every tenant's backups.

Who is recorded as having asked for a restore

Every restore writes its author into vanguard_restores.requested_by. By default that is auth()->user()->getAuthIdentifier() — the default guard. If your dashboard authenticates on another one, say so, or the column stays null for every restore run from the interface:

Vanguard::restoreActor(fn (): ?string => Auth::guard('ops')->user()?->email);

A restore run from the console has no authenticated user: the row then names the shell account and the machine, deploy@web-1.

Where the run came from is a separate column, origin — api or console — so requested_by holds an identity and nothing else. It is null on rows written before that column existed; a path nobody recorded is not the same fact as api.

Multi-tenancy

'tenancy' => [
    'enabled'      => true,
    'tenant_model' => \App\Models\Tenant::class,
    'tenant_key'   => 'id',
],

Single database under Row-Level Security

By default Vanguard expects one database per tenant (stancl/tenancy's DatabaseTenancyBootstrapper): a tenant backup dumps the tenant's own database. A multi-tenant application can instead keep one database for every tenant, rows isolated by PostgreSQL Row-Level Security, with only the storage split per tenant (FilesystemTenancyBootstrapper). There is then no tenant connection to dump, and every tenant backup used to fail on it.

Opt in with a runtime variable:

VANGUARD_TENANCY_DATABASE_MODE=single   # default: multi
multi (default) single
Landlord backup central DB + files the shared DB + files
Tenant backup tenant DB (+ files when asked) tenant files only (storage_path() of the tenant)
Tenant connection read yes never
--no-filesystem on a tenant DB only refused: nothing to back up
Tenant restore tenant DB (+ files) files only; --restore-storage (API: restore_storage=true) required, --database refused
  • Schedule both: schedule.landlord (the shared database) and schedule.tenants (each tenant's files). Both are on by default.
  • Tenant records carry sources = ["filesystem"] and meta.database_mode = "single"; the dashboard labels tenant backups files only.
  • A tenant restore without the files is refused everywhere — console, API (400) and queued job — before anything is downloaded: it would restore nothing. The dashboard's restore dialog asks for the files in this mode.
  • A database restore asked for on a tenant backup is announced (skipping database) and logged, never silently skipped.
  • Limit: restoring the rows of a single tenant out of the shared database is not covered — the shared database comes back as a whole, from a landlord backup.
  • Any other value than multi / single is logged at boot and refused by tenant backups and restores.

Pair it with a dedicated backup connection (below): on a database under Row-Level Security, the application role cannot dump it.

Dedicated backup/restore connections (PostgreSQL RLS)

Under Row-Level Security the application's role only sees the rows of the current tenant. A pg_dump taken as that role is a dump of what it can see, and under FORCE ROW LEVEL SECURITY even the table owner is filtered on restore. Name connections of their own for the dump and for the restore (runtime variables, connection names from config/database.php):

VANGUARD_BACKUP_CONNECTION=pgsql_backup     # default: unset
VANGUARD_RESTORE_CONNECTION=pgsql_restore   # default: unset
// config/database.php — same server and database as 'pgsql', other roles
'pgsql_backup' => [
    'driver' => 'pgsql',
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT', '5432'),
    'database' => env('DB_DATABASE'),
    'username' => env('DB_BACKUP_USERNAME'),
    'password' => env('DB_BACKUP_PASSWORD'),
    'charset' => 'utf8',
    'search_path' => 'public',
    'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'pgsql_restore' => [
    // same keys, with DB_RESTORE_USERNAME / DB_RESTORE_PASSWORD
],

The roles, on the PostgreSQL side:

-- Dump: sees every row, cannot write anything.
-- FOR ROLE names the role that creates the tables (the one migrations run
-- as): without it the default privileges only cover objects created by
-- whoever runs this statement, and the next migration's tables are unreadable.
CREATE ROLE app_backup LOGIN PASSWORD '...' BYPASSRLS;
ALTER ROLE app_backup SET default_transaction_read_only = on;
GRANT CONNECT ON DATABASE app TO app_backup;
GRANT USAGE ON SCHEMA public TO app_backup;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_backup;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO app_backup;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public GRANT SELECT ON TABLES TO app_backup;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA public GRANT SELECT ON SEQUENCES TO app_backup;

-- Restore: owns the tables, so it can recreate them, and bypasses RLS
ALTER ROLE app_owner BYPASSRLS;
  • Unset, nothing changes: the dump uses the central connection (tenancy.database.central_connection, else database.default) and the landlord restore writes through database.default, exactly as before.
  • A connection that is not declared, or whose driver differs from the one it replaces, fails the backup or restore before anything is dumped or written — there is no silent fallback to the application role, which is the very failure being avoided.
  • --database= (restore rehearsal) still applies, on top of the restore connection: only the database name moves.
  • Only the landlord/global dump and restore use them. In multi mode, per-tenant dumps keep using the tenant connection.
  • The health screen checks the database clients of these connections and names them under connections (names only, never credentials).
  • Vanguard's own tables (vanguard_backups, vanguard_restores) keep being read and written through the application connection.

PostgreSQL: matching pg_dump to the server

pg_dump cannot dump a server of a newer major version: a pg_dump 16 against a PostgreSQL 18 server fails with server version mismatch, and only once the backup has started. Two settings, both read at runtime:

# Which client binaries to run. A configured path is used as is (never
# replaced by auto-detection); unset, /usr/bin, /usr/local/bin, … are searched.
VANGUARD_PG_DUMP_BINARY=/usr/lib/postgresql/18/bin/pg_dump
VANGUARD_PSQL_BINARY=/usr/lib/postgresql/18/bin/psql

# Compare the pg_dump and server majors before every dump (default: off)
VANGUARD_PG_VERSION_CHECK=strict   # off | warn | strict
VANGUARD_PG_VERSION_CHECK Before each PostgreSQL dump
off (default) nothing: no pg_dump --version, no extra connection — the behaviour of every earlier version
warn an older pg_dump is logged; the dump runs anyway
strict an older pg_dump refuses the backup before the dump starts, naming both versions, the binary and VANGUARD_PG_DUMP_BINARY (and /usr/lib/postgresql/<server>/bin/pg_dump when it is installed)
  • The server version (SHOW server_version_num) is read over the connection the dump uses — the dedicated backup connection when one is configured.
  • A pg_dump of the same major or newer passes. A version that cannot be read (binary missing, server unreachable) is logged and never blocks: unknown is not a proven mismatch, and the dump reports its own error.
  • An unknown value is logged and treated as warn.
  • With the check on, the health screen adds version, server_version, version_check and version_compatible to the pgsql row of database_clients; a mismatch is ok: false in strict, degraded in warn. With the check off, the row is unchanged.
  • Auto-detection never looks into /usr/lib/postgresql/<major>/bin: with several clients installed (common on Debian/Ubuntu, which put only the newest one on the PATH through wrappers), pin the binary.

Getting the right client into the image:

# Debian/Ubuntu base: the PGDG repository ships every major
RUN apt-get update && apt-get install -y curl ca-certificates gnupg lsb-release \
 && install -d /usr/share/postgresql-common/pgdg \
 && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
      -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \
 && echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
      > /etc/apt/sources.list.d/pgdg.list \
 && apt-get update && apt-get install -y postgresql-client-18

# Alpine base
RUN apk add --no-cache postgresql18-client

Example — a multi-tenant application with a single database isolated by Row-Level Security, on a PostgreSQL 18 server:

VANGUARD_TENANCY_DATABASE_MODE=single
VANGUARD_BACKUP_CONNECTION=pgsql_backup      # BYPASSRLS, read-only role
VANGUARD_RESTORE_CONNECTION=pgsql_restore    # table owner, BYPASSRLS
VANGUARD_PG_DUMP_BINARY=/usr/lib/postgresql/18/bin/pg_dump
VANGUARD_PSQL_BINARY=/usr/lib/postgresql/18/bin/psql
VANGUARD_PG_VERSION_CHECK=strict

Encrypted archives (age)

Off by default: archives are plain .tar bundles. When backups hold sensitive data, Vanguard can encrypt every archive with age before it leaves the machine, to one or more public keys. The private key never sits on the server: it is handed over at restore time, by the operator, and Vanguard never stores, copies or logs it.

1. Generate the key off the server, on an admin workstation:

age-keygen -o backup-key.txt          # private key: keep it OFF the server
age-keygen -y backup-key.txt          # prints the public key: age1...

Store backup-key.txt somewhere that survives the loss of the server (a password manager, an offline copy). Lose it and the archives are unrecoverable. Two recipients — two keys kept by two people — remove that single point of failure.

2. Install age on the server (apt install age, brew install age, …) and give Vanguard the public key(s) — runtime variables:

VANGUARD_ENCRYPTION_ENABLED=true                     # default: false
VANGUARD_ENCRYPTION_RECIPIENTS=age1...,age1...       # public keys, comma or newline separated
# VANGUARD_ENCRYPTION_RECIPIENTS_FILE=/etc/backup/recipients.txt   # one per line (age -R)
# VANGUARD_AGE_BINARY=/usr/bin/age                   # default: auto-detected

SSH public keys (ssh-ed25519 …, ssh-rsa …) are accepted as recipients too.

What changes once enabled

  • Archives are stored as {name}.tar.age on every destination (local, remote, FTP); the plaintext .tar is removed from the tmp directory as soon as it is encrypted.
  • checksum stays the SHA-256 of what is stored (the encrypted file), so --no-verify / verify_checksum work unchanged before decryption. The record's meta.encryption keeps format, the number of recipients and the plaintext_checksum, checked again after decryption.
  • A backup with no usable recipient, or without the age binary, fails before anything is dumped. There is no fallback to a plaintext archive. An unusable setup is also logged at boot, and the health payload gains an encryption section (binary, recipient count — never the keys).
  • Download, prune and delete handle .tar.age files like any other archive.

Restoring — console only:

php artisan vanguard:restore 42 --identity=/secure/backup-key.txt
# or type / pipe the key instead of leaving a file on the server:
php artisan vanguard:restore 42 --identity-stdin --restore-storage
  • Without --identity / --identity-stdin, an encrypted backup is refused before anything is downloaded.
  • --identity-stdin reads the key at a hidden prompt (or from stdin when piped), writes it to a 0600 file under vanguard.tmp_path for age to read, and erases it when the restore ends, whatever happened.
  • The dashboard and POST /api/backups/{id}/restore refuse encrypted backups with a 400 naming the command to run: a private key must not travel over HTTP nor sit in a queue payload. The dashboard marks them encrypted and shows that command in the restore dialog.
  • A key protected by a passphrase makes age ask for it on a terminal: use it with --identity from an interactive session.
  • The decrypted archive lives in the session tmp directory (0700) for the duration of the restore. Keep VANGUARD_TMP_PATH off shared volumes.

Rotating keys: add the new public key to the recipients, deploy, then remove the old one once its last archive has aged out of retention. Archives keep the recipients they were encrypted to.

Example — multi-tenant application on a single database isolated by Row-Level Security (PostgreSQL 18), holding sensitive data:

VANGUARD_TENANCY_DATABASE_MODE=single
VANGUARD_BACKUP_CONNECTION=pgsql_backup       # BYPASSRLS, read-only role
VANGUARD_RESTORE_CONNECTION=pgsql_restore     # table owner, BYPASSRLS
VANGUARD_ENCRYPTION_ENABLED=true
VANGUARD_ENCRYPTION_RECIPIENTS=age1...primary,age1...second-custodian
VANGUARD_REMOTE_ENABLED=true

The landlord backup dumps the whole shared database through the backup role, each tenant backup archives that tenant's files, and every archive is encrypted before it is uploaded.

Cloudflare R2

Off by default. R2 speaks the S3 API, so it is used through the existing remote destination; Vanguard only declares the disk for you. Nothing changes for an s3 disk you already use.

  1. In the Cloudflare dashboard, create a bucket (R2 → Create bucket). For data that must stay in the EU, choose the EU jurisdiction at creation.
  2. Create an R2 API token (Manage R2 API Tokens) with the Object Read & Write permission, restricted to that bucket. Keep the access key id and secret; the account id is shown on the R2 overview page.
  3. Install the S3 adapter and set the runtime variables:
composer require league/flysystem-aws-s3-v3
VANGUARD_R2_ACCOUNT_ID=0123456789abcdef0123456789abcdef
VANGUARD_R2_ACCESS_KEY_ID=...
VANGUARD_R2_SECRET_ACCESS_KEY=...
VANGUARD_R2_BUCKET=app-backups
# VANGUARD_R2_JURISDICTION=eu        # only for a bucket created in the EU jurisdiction
# VANGUARD_R2_DISK=vanguard-r2       # disk name (default)
# VANGUARD_R2_ENDPOINT=              # explicit endpoint, overrides the one built from the account

VANGUARD_REMOTE_ENABLED=true
VANGUARD_REMOTE_DISK=vanguard-r2
VANGUARD_REMOTE_PATH=vanguard-backups

With a bucket and an account id (or an endpoint), Vanguard declares this disk at registration — the equivalent of:

'vanguard-r2' => [
    'driver' => 's3',
    'key' => env('VANGUARD_R2_ACCESS_KEY_ID'),
    'secret' => env('VANGUARD_R2_SECRET_ACCESS_KEY'),
    'region' => 'auto',
    'bucket' => env('VANGUARD_R2_BUCKET'),
    'endpoint' => 'https://<account_id>.r2.cloudflarestorage.com', // .eu. with the EU jurisdiction
    'use_path_style_endpoint' => true,
    'throw' => true,
    'request_checksum_calculation' => 'when_required',
    'response_checksum_validation' => 'when_required',
],
  • Never overwrites: a disk already declared under that name in config/filesystems.php wins, and no other disk (s3 included) is touched. To declare it yourself under another name, build it from a service provider with Vanguard::r2DiskConfig().
  • No ACL and no visibility are set: R2 does not implement object ACLs.
  • The two checksum keys stop recent AWS SDKs (3.337+) from adding CRC checksum headers to every request, which S3-compatible services do not all accept; older SDKs ignore them.
  • An unknown VANGUARD_R2_JURISDICTION is logged and the disk is not declared — guessing would send archives to another jurisdiction.
  • The health screen's write/read/delete probe covers R2 like any disk, and names the missing league/flysystem-aws-s3-v3 package when it is absent; vanguard:install does too.
  • Retention: Vanguard prunes its own archives (VANGUARD_RETENTION_DAYS). An R2 object lifecycle rule on the bucket (e.g. delete after 90 days) is a useful second guard, and a bucket lock rule protects archives from deletion by a compromised server. Keep the lifecycle longer than Vanguard's retention so the two do not race.
  • R2 charges no egress fees: downloading an archive to restore it costs nothing beyond the operation itself.
  • Combine with encrypted archives so the bucket only ever holds ciphertext.

Restoring a backup

php artisan vanguard:restore {id}
                  [--source=local|remote|ftp]
                  [--database=name]
                  [--no-verify] [--no-db]
                  [--restore-storage] [--wipe-storage]
                  [--identity=file | --identity-stdin]
                  [--force]
Option Effect
--source= Which destination the bundle is read back from. Omit it and Vanguard uses the first destination that actually holds a path (local, then remote, then ftp) — a backup that only ever reached S3 restores without any flag. Pass it explicitly to force one, e.g. to restore the remote copy while a local one exists. Asking for a destination the backup never reached is an error naming the ones available.
--database= Write to this database instead of the target's own, for this run only — see below
--no-verify Skip the SHA-256 checksum check
--no-db Restore the filesystem only
--restore-storage Also restore the filesystem (opt-in; the database alone is restored by default)
--wipe-storage Replace instead of merge — see below. Requires --restore-storage
--identity= age private key file, for an encrypted archive — see Encrypted archives
--identity-stdin Read that key at a hidden prompt, or from stdin when piped
--force Skip every confirmation prompt

Rehearsing a restore (--database=)

A backup nobody has ever restored is a backup nobody has verified. --database= lets you find out without repointing the application: the restore runs against the target's own server, with its own credentials and its own client binary — only the database it writes into moves.

# Landlord backup, replayed into a scratch database
php artisan vanguard:restore 42 --database=vanguard_rehearsal

# Same for a tenant backup: the tenant's connection is what gets redirected,
# so the dump lands on the tenant's own server under the tenant's own user
php artisan vanguard:restore 43 --database=vanguard_rehearsal

The run is recorded as a rehearsal: vanguard_restores.target_database holds the database it wrote to, and both the Restores screen and In Progress tag the row Rehearsal. A rehearsal that read like a real restore would report data replaced that was never touched.

Create the database first — Vanguard writes into it, it does not create it. The name must be a plain identifier (letters, digits, _, ., -); anything else is refused rather than quoted, because the value reaches a command line. Nothing is written back to your configuration: the application, its other connections and any later restore in the same process are untouched. The command says which database it is about to write to, on its own line, before it asks to proceed.

--database= is CLI-only, like --wipe-storage. The dashboard API refuses the parameter outright rather than ignoring it: a rehearsal you believe is going to a scratch database and silently goes to the real one is the worst thing this option could do.

Merge (default) vs. replace (--wipe-storage)

A filesystem restore merges by default: the archive is extracted over storage/, so files created after the backup was taken survive it. That is the safe behaviour, and it is what you want most of the time.

--wipe-storage gives you the point-in-time state instead. Before extracting, it empties the directories listed in vanguard.sources.filesystem_paths — only those, resolved under storage_path():

  • storage/ itself is never wiped. Logs, framework/ caches and sessions live there and are not in the backup, so they must survive the restore.
  • Each directory node is kept and only its content removed, so permissions and any symlink pointing at it stay intact.
  • A configured path that resolves outside storage_path() (a stray '', . or ../..) is refused and logged rather than followed.

It only makes sense together with a filesystem restore, and it is not made to imply it: --wipe-storage on its own exits with an error. Interactively it asks a second, separate confirmation listing the exact directories about to be erased; --force skips both prompts.

# Merge: restore files, keep anything added since the backup
php artisan vanguard:restore 42 --restore-storage

# Replace: storage/app becomes exactly what the backup holds
php artisan vanguard:restore 42 --restore-storage --wipe-storage

The restore history

Every restore leaves one row, whichever path it came from — the endpoint, or vanguard:restore on a console. The Restores screen in the dashboard reads it: status, target, who asked, the source the bundle was read from, and the exact error. Rows are never pruned; they weigh a few bytes and answer "who restored what, when" long after the archive itself is gone.

GET /vanguard/api/restores?status=failed&tenant_id=acme&per_page=20
GET /vanguard/api/restores/{id}

A restore whose worker is killed halfway leaves its row at running; vanguard:cleanup-tmp closes it past vanguard.queue.timeout and sends the failure notification. See StaleRunReaper.

--wipe-storage is CLI-only on purpose. The dashboard API (POST /api/backups/{id}/restore) accepts verify_checksum, restore_db, restore_storage and source — and answers 400 for wipe_storage or database, on presence alone.

Frontend architecture

resources/
├── css/vanguard.css
└── js/vanguard/
    ├── app.js                  ← Vue entry point
    ├── App.vue                 ← layout, navigation, realtime orchestration
    ├── composables/
    │   ├── useApi.js           ← fetch wrapper (CSRF, base URL via inject)
    │   ├── useBackups.js       ← shared state: stats, backups, tenants
    │   ├── useOperations.js    ← what is running, waiting and recently failed
    │   ├── useRestores.js      ← shared state: the restore history
    │   ├── useRealtime.js      ← SSE / polling driver (auto-fallback)
    │   ├── reconcile.js        ← merges a refresh into the rows already on screen
    │   └── useToast.js         ← global toast notifications
    ├── components/
    │   ├── BackupTable.vue     ← reusable table (with or without actions)
    │   ├── StatCards.vue
    │   ├── RunModal.vue
    │   ├── RestoreModal.vue
    │   ├── DeleteModal.vue
    │   ├── BulkDeleteModal.vue
    │   ├── VBadge.vue          ← status badge (completed/running/failed/pending)
    │   ├── VPagination.vue
    │   ├── VToast.vue
    │   └── RealtimeIndicator.vue  ← Live / Polling / Offline dot in sidebar
    └── pages/
        ├── Dashboard.vue
        ├── Operations.vue      ← In Progress: running, waiting, failed in the last day
        ├── Backups.vue         ← full list with status/type filters + pagination
        ├── Restores.vue        ← the restore history, status/tenant filters + pagination
        └── Tenants.vue

The Blade layout is a minimal shell — mounts Vue and passes config via data-* attributes. No inline JS, no global variables.

Extending Vanguard — IoC bindings

All core services are registered through the Laravel container and can be swapped with custom implementations in your AppServiceProvider (or any service provider that boots after VanguardServiceProvider).

Container overview

Class Registration Notes
DatabaseDriver singleton Stateless — safe to share
StorageDriver singleton Stateless — safe to share
TenancyResolver singleton Stateless — safe to share
BackupStorageManager bind (transient) Holds session-scoped tmp path
BackupManager bind (transient) Gets a fresh BackupStorageManager per job
RestoreService bind (transient) Gets a fresh BackupStorageManager per job

Why transient for BackupManager? Long-running queue workers reuse the same process across many jobs. A singleton BackupManager would leak the tmp directory path from job N into job N+1. Always use bind() when overriding these classes.

Swap the BackupManager

// app/Providers/AppServiceProvider.php
use App\Backup\CustomBackupManager;
use SoftArtisan\Vanguard\Services\BackupManager;
use SoftArtisan\Vanguard\Services\BackupStorageManager;
use SoftArtisan\Vanguard\Services\TenancyResolver;
use SoftArtisan\Vanguard\Services\Drivers\DatabaseDriver;
use SoftArtisan\Vanguard\Services\Drivers\StorageDriver;

public function register(): void
{
    $this->app->bind(BackupManager::class, fn ($app) => new CustomBackupManager(
        $app->make(DatabaseDriver::class),
        $app->make(StorageDriver::class),
        $app->make(BackupStorageManager::class),
        $app->make(TenancyResolver::class),
    ));
}

Your CustomBackupManager extends BackupManager and overrides only what you need:

namespace App\Backup;

use SoftArtisan\Vanguard\Models\BackupRecord;
use SoftArtisan\Vanguard\Services\BackupManager;

class CustomBackupManager extends BackupManager
{
    public function backupTenant(mixed $tenant, array $options = []): BackupRecord
    {
        // Custom pre-backup hook
        \Log::info('Starting custom backup for tenant', ['id' => $tenant->getTenantKey()]);

        return parent::backupTenant($tenant, $options);
    }
}

Swap the DatabaseDriver

Useful to add support for a custom dump tool or encryption layer:

use App\Backup\EncryptedDatabaseDriver;
use SoftArtisan\Vanguard\Services\Drivers\DatabaseDriver;

$this->app->singleton(DatabaseDriver::class, EncryptedDatabaseDriver::class);

Swap the TenancyResolver

Override tenant resolution when you don't use stancl/tenancy or when your tenant model has a non-standard structure:

use App\Backup\CustomTenancyResolver;
use SoftArtisan\Vanguard\Services\TenancyResolver;

$this->app->singleton(TenancyResolver::class, CustomTenancyResolver::class);

Swap the VanguardScheduler

Replace the scheduler entirely to take full control of when backups run:

use App\Backup\CustomVanguardScheduler;
use SoftArtisan\Vanguard\Console\VanguardScheduler;

$this->app->singleton(VanguardScheduler::class, CustomVanguardScheduler::class);

Per-tenant schedule customization

Via the vanguard_schedule column (recommended)

Each tenant can carry its own cron expression. Add the column via a migration:

Schema::table('tenants', function (Blueprint $table) {
    $table->string('vanguard_schedule')->nullable();
});

Then set it per tenant:

$tenant->update(['vanguard_schedule' => '0 3 * * 1']); // Every Monday at 03:00

VanguardScheduler reads $tenant->vanguard_schedule automatically — no extra code needed. Tenants without the column (or with null) fall back to the global schedule defined in config/vanguard.php.

Via a custom TenancyResolver

For more complex logic (e.g. schedule stored in Redis, driven by a feature flag, or computed from the tenant's timezone):

namespace App\Backup;

use SoftArtisan\Vanguard\Services\TenancyResolver;

class CustomTenancyResolver extends TenancyResolver
{
    public function tenantSchedule(mixed $tenant): ?string
    {
        // Example: honour the tenant's local timezone
        $tz   = $tenant->timezone ?? 'UTC';
        $hour = (new \DateTime('02:00', new \DateTimeZone($tz)))
            ->setTimezone(new \DateTimeZone('UTC'))
            ->format('G');

        return "0 {$hour} * * *";
    }
}

Register it as a singleton before VanguardServiceProvider boots (or in a provider with a higher priority):

$this->app->singleton(TenancyResolver::class, CustomTenancyResolver::class);

Multiple landlord schedules

The default scheduler registers one cron entry for the landlord backup. To run multiple backup types at different times (e.g. database nightly, filesystem weekly), swap the VanguardScheduler with a custom subclass:

namespace App\Backup;

use Illuminate\Console\Scheduling\Schedule;
use SoftArtisan\Vanguard\Console\VanguardScheduler;

class MultiScheduleVanguardScheduler extends VanguardScheduler
{
    public function schedule(Schedule $schedule): void
    {
        if (! config('vanguard.schedule.enabled', true)) {
            return;
        }

        $tz = config('vanguard.schedule.timezone', config('app.timezone', 'UTC'));

        // ── Database-only landlord backup — every night at 02:00 ──────────────
        $this->scheduleCommand(
            $schedule,
            'vanguard:backup --landlord --no-filesystem',
            '0 2 * * *',
            $tz,
        );

        // ── Full landlord backup (DB + filesystem) — Sundays at 03:00 ────────
        $this->scheduleCommand(
            $schedule,
            'vanguard:backup --landlord',
            '0 3 * * 0',
            $tz,
        );

        // ── Per-tenant backups — keep the default per-tenant logic ────────────
        if (config('vanguard.schedule.tenants', true) && $this->tenancy->isEnabled()) {
            foreach ($this->tenancy->allTenants() as $tenant) {
                $cron = $this->tenancy->tenantSchedule($tenant) ?? $this->globalCron();
                $this->scheduleCommand(
                    $schedule,
                    "vanguard:backup --tenant={$tenant->getTenantKey()}",
                    $cron,
                    $tz,
                );
            }
        }

        // ── Pruning and tmp cleanup — inherited defaults ───────────────────────
        if (config('vanguard.retention.enabled', true)) {
            $schedule->command('vanguard:prune')
                ->daily()->timezone($tz)->withoutOverlapping()->runInBackground();
        }

        $schedule->command('vanguard:cleanup-tmp')
            ->hourly()->timezone($tz)->withoutOverlapping()->runInBackground();
    }
}

Register it in your service provider before VanguardServiceProvider (or override in AppServiceProvider::register()):

use App\Backup\MultiScheduleVanguardScheduler;
use SoftArtisan\Vanguard\Console\VanguardScheduler;

$this->app->singleton(VanguardScheduler::class, MultiScheduleVanguardScheduler::class);

scheduleCommand() and globalCron() are protected methods — they are part of the extension API and will not change between patch releases.