Looking to hire Laravel developers? Try LaraJobs

laravel-archivable maintained by boysfromthefactory

Description
Move old Eloquent records to a separate archive database, the Prunable way.
Last update
2026/11/05 04:02 (dev-main)
License
Downloads
1

Comments
comments powered by Disqus

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