bjthecod3r / laravel-recordables
Record snapshots of Eloquent model data over time.
Requires
- php: ^8.2
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^11.0 || ^12.0 || ^13.0
- illuminate/events: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- nesbot/carbon: ^2.0 || ^3.0
Requires (Dev)
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.1
- phpunit/phpunit: ^11.0 || ^12.0
This package is auto-updated.
Last update: 2026-05-16 19:44:09 UTC
README
Laravel Recordables
A lightweight Laravel package for recording snapshots of Eloquent model data over time.
Installation
composer require bjthecod3r/laravel-recordables
Publish the config and migration:
php artisan vendor:publish --tag=recordables-config php artisan vendor:publish --tag=recordables-migrations php artisan migrate
Quick start
use BJTheCod3r\Recordables\Concerns\HasRecordings; use BJTheCod3r\Recordables\Contracts\Recordable; use Illuminate\Database\Eloquent\Model; class Product extends Model implements Recordable { use HasRecordings; protected array $recordable = [ 'price', 'stock', 'views', ]; }
$product = Product::create([...]); // creates a recording $product->update(['price' => 200]); // creates a recording $product->record(); // manual snapshot
For computed/derived snapshots, override toRecording() instead of
declaring $recordable:
public function toRecording(): array { return [ 'price' => $this->price, 'profit_margin' => $this->calculateProfitMargin(), ]; }
Recording API
$product->record( data: ['price' => 200], changeType: 'synced', changeSource: 'cms_sync', causer: auth()->user(), recordedAt: now()->subDay(), ); $product->recordIfChanged(); $product->recordSilently(); $product->recordOnQueue();
Retrieval
$product->recordings; // MorphMany relation $product->latestRecording(); $product->firstRecording(); $product->recordingAt(now()->subWeek()); $product->recordingsBetween($start, $end);
Analytics
All analytics helpers return immutable, JSON-serializable value objects.
$history = $product->recordingHistory('price'); // History $growth = $product->recordingGrowth('price'); // Growth $delta = $product->recordingDelta('price'); // ?Delta $minmax = $product->recordingMinMax('price'); // ?MinMax $trend = $product->recordingTrend('price'); // Trend enum $average = $product->recordingAverage('price'); // float $chart = $product->recordingChartData('price', 'day'); // ChartData $growth->isPositive(); $growth->percentage; // 50.0 $growth->toArray(); // JSON-friendly
Pass a period to switch from all-time growth to period-over-period:
$product->recordingGrowth('price', period: 'month'); // this month vs last $product->recordingGrowth('views', period: 'week'); $product->recordingGrowth('stock', period: 'day');
Boundaries are calendar-aligned via Carbon (startOfWeek, startOfMonth,
…), so 'week' is "this week vs last week", not "last 7 days vs the 7 days
before that." Supported periods: hour, day, week, month, year.
Each side resolves to the latest recording within its window that
carries the metric.
Non-throwing variant for both modes:
$product->recordingGrowthOrNull('price'); $product->recordingGrowthOrNull('price', period: 'month');
Events
RecordingCreating— cancellable; call$event->cancel()from a listener to skip persistence.RecordingCreated— fired after persistence.
When a recording is skipped (disabled, unchanged, or cancelled), record()
and recordIfChanged() return null — check the return value rather than
listening for a separate event.
Testing
use BJTheCod3r\Recordables\Facades\Recordables; Recordables::fake(); $product->update(['price' => 100]); Recordables::assertRecorded($product); Recordables::assertRecordedTimes($product, 1); Recordables::assertRecordedWith($product, fn ($data) => $data['price'] === 100); Recordables::assertNotRecorded($otherProduct); Recordables::assertNothingRecorded();
Exceptions
All package exceptions extend RecordablesException:
MissingRecordableDefinitionException— no$recordableproperty and notoRecording()override.InvalidRecordableMetricException— analytics asked for a metric absent from every recording.InsufficientRecordingsException— fewer than two comparable points (e.g., growth needs two).NonNumericMetricException— analytics ran against a metric stored as a non-numeric value (string, bool, array). Carriesmetric,recordableClass, andactualType.RecordingFailedException— persistence error wrapper used inside queued jobs.
Metrics stored as null are silently skipped (treated as "no sample this
time") so missing values from integrations don't break analytics.
Pruning
php artisan recordables:prune --days=90
php artisan recordables:prune --keep=100 --model="App\Models\Product"
Schedule it:
Schedule::command('recordables:prune')->daily();
Per-model overrides
class Product extends Model implements Recordable { use HasRecordings; protected array $recordable = ['price', 'stock']; protected bool $recordOnCreate = true; protected bool $recordOnUpdate = true; protected bool $recordOnlyOnChange = true; protected ?int $keepRecordingsForDays = 90; protected ?int $keepRecordingsCount = null; }
License
MIT