orchid/log
Logging for Laravel Orchid: capture application events and review them right in your admin panel. orchid/log gives teams a simple way to monitor activity, trace issues, and maintain an audit trail without leaving the Orchid dashboard.
Prerequisites. This package only pays off inside an application that already runs Orchid Platform. It is a subtree split of theOrchid/Platform, not a standalone activity-log library. If orchid/platform is not installed, stop here — installing both to obtain logging is a much larger commitment than the problem warrants.
Install and publish. In practice you rarely require this directly; it usually arrives as a platform dependency. Treat it as pinned-to-platform: match the exact orchid/platform tag rather than floating a ^ range.
composer require orchid/log # or confirm it's already in composer.lock via orchid/platform
php artisan vendor:publish --provider="Orchid\Log\LogServiceProvider"
php artisan migrate
After publishing you get a config file and a migration for the log table. Read the config first — it is the entire API surface: the list of loggable models, the enabled events, and the attribute capture rules. Everything else in the package is glue around that file.
Where to look first, in order:
config/log.php (or whatever the published filename is) — the model registry and event list.src/Traits/ — this is the contract you actually apply to your models; read it to see which events it hooks and how it decides what attributes to snapshot.First use case — a one-model spike, timeboxed to a day. Pick a low-risk model in an internal admin route (a CMS page, a category, a settings row). Apply the trait, add the model to the config registry, run the migration, edit the record once through the admin panel, then open the log viewer and confirm the row appears with before/after values and the acting user id.
use Orchid\Log\Traits\Loggable;
class Page extends Model
{
use Loggable;
// If the trait reads per-model config, declare the watched attributes
// explicitly rather than defaulting to `*`. See Gotchas.
}
If that produces a correct row on create, update, and delete, the package does what you need. If it doesn't, you have lost a day, not a sprint — that is the point of the spike.
Trait-per-model, registry-in-config. There are two ways models get logged: the trait on the model, and the config registry. Use both consciously. The trait is the mechanism; the config list is your single source of truth for which models are audited. Keeping the registry accurate means a security reviewer can read one file and answer "is PII logged?" without grepping for use Loggable.
// config/log.php (shape varies by version — confirm keys after publishing)
'models' => [
\App\Models\Page::class => ['created', 'updated', 'deleted'],
\App\Models\Category::class => ['created', 'updated', 'deleted'],
// \App\Models\User::class => [], // deliberately excluded
],
Whitelist attributes, never default to *. The default "snapshot everything" behavior is how password hashes, API tokens, and personal data end up in an audit table with weaker access control than the source table. Declare watched attributes per model, and add an explicit redaction list for anything that changes but shouldn't be retained.
protected static function loggableAttributes(): array
{
return ['title', 'slug', 'status', 'published_at'];
}
protected static function loggableRedacted(): array
{
return ['password', 'remember_token', 'api_token'];
}
Make this a security-review gate, not a code-review comment. A reviewer can't catch a PII leak in a diff that just says use Loggable;.
Render per-record history inside your existing Orchid screen. The built-in viewer is a global log browser; the higher-value pattern is a "History" section on the edit screen the editor is already looking at. Query the log model by model type + id and render a compact timeline in a Layout\Modal or a tab.
$history = Log::query()
->where('model_type', Page::class)
->where('model_id', $page->id)
->with('user')
->latest()
->paginate(25);
This is where the package earns its keep — the content editor answers "who changed the headline?" without a ticket. Keep the global viewer for ops, the per-record tab for editors.
Read the log headlessly for anything automated. The persistence layer is a normal Eloquent model and does not require the Orchid UI. That means you can build your own reports, exports, or Slack digests against it without touching the admin screen:
// "What changed on this record in the last 24h?"
Log::where('model_type', Page::class)
->where('created_at', '>=', now()->subDay())
->get()
->map(fn ($l) => [$l->user_id, $l->action, $l->old_values, $l->new_values]);
Confirm this early — it determines whether the package is a viewer-only purchase or something you can build on.
Diff at read time, not write time. Store raw before/after payloads and compute the human-readable diff in the presenter. Storing rendered diff strings makes the data useless for machine consumers and brittle when you change presentation.
Wrap it behind your own thin interface. Do not call the vendor log model directly from controllers or Blade. One internal service (ActivityLog::forModel($model), ActivityLog::record(...)) means that if the package stalls or you fork it, the swap is one class, not a grep-and-replace across the app.
Retention as a scheduled job from day one. The table grows monotonically and is read rarely — the classic shape that degrades quietly for months and then bites during a restore.
// app/Console/Kernel.php
$schedule->call(fn () => Log::where('created_at', '<', now()->subDays(180))->delete())
->dailyAt('03:00');
Chunk the delete on large tables; a single unbounded DELETE will lock and time out. Consider writing old rows to cold storage before pruning if audit queries occasionally reach further back than the retention window.
Test the log, not just the model. The failure mode nobody notices is silent: logging stops and everyone assumes "nothing happened." Add assertions to your feature tests.
$this->put(route('platform.pages.edit.post', $page), ['title' => 'New'])
->assertSessionHasNoErrors();
$this->assertDatabaseHas('logs', [
'model_type' => Page::class,
'model_id' => $page->id,
'user_id' => $this->admin->id,
]);
That single test catches the most common breakage: a platform upgrade silently changing how the listener is registered.
The write is synchronous inside Eloquent events. Every create/update/delete on a logged model adds an INSERT inside the request path. On high-write models (bulk imports, queue workers, seeded data) this is real amplification. Measure it before rollout: log a hot model, run the real import, and time the difference. If it hurts, the fix is to move logging off the request path — but confirm first whether dispatch can be queued in your version; if not, that's a fork-level change, not a config toggle.
A failed log write can abort a legitimate transaction. If the log INSERT throws inside a saving handler, the business write rolls back for a logging reason. Decide this deliberately — isolate the write in its own try/catch and log the logging failure elsewhere. Decide once, in code, not by accident during an incident.
Recursion. If the logged model's own events trigger further model writes (touch chains, observers updating counters, the user model being touched), you get a loop or an exploding row count. Watch for models that write to themselves. Also exclude the log model itself from logging.
No-op updates produce noise. save() on a model with nothing dirty, or a change that only touches updated_at, still fires an event. Filter on isDirty() against your watched attributes before recording, or your timeline fills with empty entries and editors stop trusting it.
Bulk operations bypass events. Model::where(...)->update(...) and insert() do not fire model events and therefore are not logged. This is the single biggest correctness surprise. Any admin action that uses a query-builder update is a blind spot — and worse, the absence reads as "nothing changed." Route audit-relevant bulk edits through model instances, or log them explicitly.
Absence of logs is not evidence of absence. During an incident, an empty log table looks identical to a broken listener. Instrument a heartbeat — a daily row count or a metric — so you can tell "nobody did anything" from "logging is dead." This is the failure mode that costs you the most trust.
Indexes and retention are your problem, not the package's. Check the shipped migration for a composite index on (model_type, model_id, created_at) and on user_id. If it's missing, add your own migration — the default viewer query will otherwise table-scan once you pass a few hundred thousand rows. Remember that this table shares disk, I/O, backup, and restore time with your primary workload.
Two listeners, double rows. If you already run another audit package (or wrote a custom observer), you now have two writers on the same Eloquent events. The symptom is duplicate or near-duplicate log entries that confuse everyone reading the viewer. Audit for conflicts before merge; if replacing an existing mechanism, dual-write for one release, reconcile completeness against a known change set, then cut over and keep the old path readable for a quarter.
Pin versions to the platform tag. Split repos routinely lag the monorepo. Before committing, verify the tag exists and that the CI-tested PHP/Laravel matrix matches your production versions — not the declared floor. Expect Laravel minor upgrades to be gated on the platform maintainer; that coupling is the real maintenance cost, not the package code. Because the surface is small, a vendored fork is a credible escape hatch — scope that option while it's cheap, not during an incident.
Rollback is disabling the trait. Removing the dependency requires a migration rollback and risks losing data. Design the fast switch as "remove the trait / empty the config registry," which is a config change you can ship in a hotfix.
Debugging checklist when entries don't appear:
php artisan config:clear after editing the config? Cached config is the most common false alarm.updated firing but not deleted, or vice versa, is a frequent half-configuration.Extension points worth knowing. The log Eloquent model (swap it if you need a different table or a tenant column), the Orchid screen class (subclass to add filters, export, or role scoping), and the trait itself (vendor a fork if you need queued dispatch or custom attribute rules). Everything else is config.
Keep the audiences separate. This package is a viewer for business users — support, content ops. It is not your engineering observability pipeline and it is not a compliance control: no immutability, no tamper-evidence, no signed export, weak retention guarantees. Keep Sentry/Telescope/centralized logs for engineers, and record the "not a compliance control" decision explicitly so it isn't relitigated each quarter.
How can I help you explore Laravel packages today?