laravel-archivable maintained by boysfromthefactory
Laravel Archivable
Move old Eloquent records to a separate archive database, the way Prunable deletes them.
Add a trait, define which records are old, and schedule model:archive. Records are copied to the archive connection with their exact stored values and then removed from the main database. You can query them later and restore them.
Installation
composer require boysfromthefactory/laravel-archivable
php artisan vendor:publish --tag=archivable-config # optional
Requires PHP 8.2+ and Laravel 12 or 13.
1. Add an archive connection
In config/database.php:
'connections' => [
// ...
'archive' => [
'driver' => env('ARCHIVE_DB_DRIVER', 'mysql'),
'host' => env('ARCHIVE_DB_HOST', '127.0.0.1'),
'port' => env('ARCHIVE_DB_PORT', '3306'),
'database' => env('ARCHIVE_DB_DATABASE', 'archive'),
'username' => env('ARCHIVE_DB_USERNAME', 'root'),
'password' => env('ARCHIVE_DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
],
],
ARCHIVE_DB_CONNECTION=archive # which connection the package uses (default: archive)
ARCHIVE_DB_HOST=127.0.0.1
ARCHIVE_DB_DATABASE=my_app_archive
ARCHIVE_CHUNK_SIZE=1000 # optional
ARCHIVE_ARCHIVED_AT_COLUMN=archived_at # optional, see below
Or: archive tables in the same database
If you don't want a separate database, archive into differently named tables on the model's own connection instead. Leave ARCHIVE_DB_CONNECTION unset (null) and give archive tables a prefix or suffix:
ARCHIVE_DB_CONNECTION=null
ARCHIVE_TABLE_SUFFIX=_archive # posts -> posts_archive
# ARCHIVE_TABLE_PREFIX=archive_ # posts -> archive_posts
In this mode each record (or chunk, for MassArchivable) is copied and deleted inside one database transaction, so a move is fully atomic.
The prefix and suffix also work with a separate archive connection.
2. Create the archive tables
The archive tables are yours to manage. Write normal migrations that target the archive connection. In same-database mode, drop the $connection property and use the prefixed or suffixed name, e.g. activity_logs_archive. The archive table needs the same columns as the source table, and its primary key must be unique, because archiving upserts on it.
return new class extends Migration
{
protected $connection = 'archive';
public function up(): void
{
Schema::create('activity_logs', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary(); // keep source ids, no auto-increment
$table->string('description');
$table->json('properties')->nullable();
$table->timestamps();
$table->timestamp('archived_at')->nullable(); // only if ARCHIVE_ARCHIVED_AT_COLUMN is set
});
}
};
When you add a column to the source table, add it to the archive table too. If the archive table is missing a column, the archive write fails loudly instead of losing data.
Usage
Archivable: model by model, with events
use BoysFromTheFactory\Archivable\Archivable;
class ActivityLog extends Model
{
use Archivable;
public function archivable(): Builder
{
return static::where('created_at', '<=', now()->subYear());
}
protected function archiving(): void
{
// runs before the model is copied and deleted
}
}
Each model is copied to the archive and then removed with delete() (or forceDelete() for soft-deletable models), so deleting/deleted events and observers run. If a deleting listener cancels the delete, the archive copy is removed again. Errors on one model are reported and do not stop the run.
MassArchivable: bulk, fast, no events
use BoysFromTheFactory\Archivable\MassArchivable;
class PageView extends Model
{
use MassArchivable;
public function archivable(): Builder
{
return static::where('created_at', '<=', now()->subMonths(6));
}
}
Rows are read and written in raw chunks, and source rows are deleted with one query per chunk. No models are hydrated, and no model events or hooks fire.
Running it
php artisan model:archive # all archivable models in app/Models
php artisan model:archive --model="App\Models\ActivityLog"
php artisan model:archive --except="App\Models\PageView"
php artisan model:archive --path=app/Domain --chunk=500
php artisan model:archive --pretend # only show how many records would move
Schedule it like model:prune:
// routes/console.php
Schedule::command('model:archive')->daily();
The chunk size comes from the model's $archivableChunkSize property, then --chunk, then archivable.chunk. The default discovery paths are set in archivable.paths.
Models that use SoftDeletes are archived whether or not they are trashed, the same as with pruning.
Querying archived records
$logs = ActivityLog::fromArchive()->where('user_id', 5)->get();
$logs->first()->isArchivedInstance(); // true
fromArchive() returns a normal Eloquent builder bound to the archive connection and table. Casts, accessors and scopes all work.
Relations of archived models resolve on the archive connection, unless the related model sets its own
$connection.
Restoring
ActivityLog::fromArchive()->find(42)->unarchive(); // runs unarchiving()/unarchived() hooks
ActivityLog::unarchiveWhere(fn ($query) => $query->where('user_id', 5));
php artisan model:unarchive "App\Models\ActivityLog" --id=42 --id=43
php artisan model:unarchive ActivityLog --all --force
Restoring copies only the columns the source table has, so archived_at and any other archive-only columns are dropped.
Customising per model
public function archiveConnection(): string { return 'cold_storage'; }
public function archiveTable(): string { return 'activity_logs_2024'; }
public $archivableChunkSize = 250;
How it stays safe
When the archive is on the model's own connection, each move happens in a single transaction.
A transaction cannot span two databases, so with a separate archive connection each record or chunk is written to the archive first and deleted from the source second. Archive writes are upserts on the primary key. If a run dies between the two steps, the next run writes the same rows again without duplicating them and then finishes the delete. In the worst case a record is in both databases for a while; it is never in neither.
Before a model is processed, the package checks three things:
- The archive connection is configured.
- The archive is not the same connection and table as the source.
- The archive table exists.
Events
| Event | When |
|---|---|
ModelArchivingStarting / ModelArchivingFinished |
Start and end of model:archive ($models) |
ModelsArchived |
After each chunk ($model, running $count) |
ModelsUnarchived |
After each chunk restored by unarchiveWhere |
Testing
composer test
ARCHIVABLE_TEST_DRIVER=mysql composer test # needs databases archivable_source and archivable_archive