softartisan / laravel-vanguard
A powerful, multi-tenant aware backup manager for Laravel with stancl/tenancy v3 support — with a beautiful dashboard.
Requires
- php: ^8.3
- illuminate/console: ^12.0
- illuminate/database: ^12.0
- illuminate/filesystem: ^12.0
- illuminate/http: ^12.0
- illuminate/queue: ^12.0
- illuminate/routing: ^12.0
- illuminate/support: ^12.0
Requires (Dev)
- laravel/pint: ^1.0
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
Suggests
- league/flysystem-aws-s3-v3: Required for S3 remote backup storage
- league/flysystem-ftp: Required for FTP backup destination (VANGUARD_FTP_ENABLED=true)
- league/flysystem-sftp-v3: Required for SFTP backup destination (VANGUARD_FTP_ENABLED=true)
- stancl/tenancy: Required for multi-tenant backup support (^3.0)
Provides
None
Conflicts
None
Replaces
None
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.middlewarestack (['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) andschedule.tenants(each tenant's files). Both are on by default. - Tenant records carry
sources = ["filesystem"]andmeta.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/singleis 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, elsedatabase.default) and the landlord restore writes throughdatabase.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
multimode, 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_dumpof 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_checkandversion_compatibleto thepgsqlrow ofdatabase_clients; a mismatch isok: falseinstrict,degradedinwarn. 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 thePATHthrough 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.ageon every destination (local, remote, FTP); the plaintext.taris removed from the tmp directory as soon as it is encrypted. checksumstays the SHA-256 of what is stored (the encrypted file), so--no-verify/verify_checksumwork unchanged before decryption. The record'smeta.encryptionkeepsformat, the number ofrecipientsand theplaintext_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
encryptionsection (binary, recipient count — never the keys). - Download, prune and delete handle
.tar.agefiles 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-stdinreads the key at a hidden prompt (or from stdin when piped), writes it to a0600file undervanguard.tmp_pathfor age to read, and erases it when the restore ends, whatever happened.- The dashboard and
POST /api/backups/{id}/restorerefuse encrypted backups with a400naming 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
--identityfrom an interactive session. - The decrypted archive lives in the session tmp directory (
0700) for the duration of the restore. KeepVANGUARD_TMP_PATHoff 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.
- In the Cloudflare dashboard, create a bucket (R2 → Create bucket). For data that must stay in the EU, choose the EU jurisdiction at creation.
- 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.
- 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.phpwins, and no other disk (s3included) is touched. To declare it yourself under another name, build it from a service provider withVanguard::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_JURISDICTIONis 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-v3package when it is absent;vanguard:installdoes 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-storageis CLI-only on purpose. The dashboard API (POST /api/backups/{id}/restore) acceptsverify_checksum,restore_db,restore_storageandsource— and answers 400 forwipe_storageordatabase, 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
BackupManagerwould leak the tmp directory path from job N into job N+1. Always usebind()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()andglobalCron()areprotectedmethods — they are part of the extension API and will not change between patch releases.