This is the next entry in a build-in-public series where I extract a real, production Laravel CRM into a reusable SaaS core, one module at a time, and ship each piece as a Composer package. So far: the foundation layer, auth on top of Fortify, multi-tenancy, and roles & permissions. This one is v0.5.0: the activity log.
And I want to be honest up front about what this module is, because it's easy to oversell.
I did not invent an audit log
The logging itself is spatie/laravel-activitylog. It's great, it's everywhere, and writing my own would be silly. So I didn't.
What I extracted is everything spatie does not give you out of the box, wrapped around it:
- every entry carries the device, IP, route, HTTP method and geolocation of the request that caused it, not just "model X changed"
- a single super-admin viewer to read all of it
- a config registry so the core logs its own events (auth, tenancy) and the host adds its domain events the same way
The value is the integration, not the storage. I say that in every post because the whole point of building in public is not pretending the car is finished when you've shipped an engine.
The one decision worth explaining: this is not a tenant feature
In my CRM, the activity log is a platform-operator tool. The person who reads it is me, the operator, not a company owner looking at their own team. So the log is global. It has no company_id. A super-admin sees everything across every tenant; a company owner never sees it at all.
That sounds like a small thing but it shaped the whole module. There's no tenant scoping to get wrong, no per-company filtering, no risk of one company reading another's trail. The access rule collapses to a single question: are you a super-admin? And super-admin, from the auth phase, is an identity flag resolved through one class, never a role you can hand out by accident.
It also means this module quietly lands the first slice of something bigger: a platform operator console. The activity log is its first screen. I kept the admin shell deliberately tiny (a header, a "super-admin" marker, a slot) so I'm not pretending to build the whole console this week.
The context wrapper
The core records request context on every entry through one place, resolving the device through the fingerprint contract the auth phase already owns (not a second user-agent parser), and redacting secrets before anything is stored.
public function forRequest(?Request $request = null): array
{
$request ??= request();
$device = $this->deviceResolver->resolve($request);
return [
'user_ip' => $request->ip(),
'user_device_type' => $device->type,
'user_os' => $device->os,
'user_browser' => $device->browser,
'route_name' => $request->route()?->getName(),
'request_method' => $request->getMethod(),
'full_url' => $this->redactUrl($request->fullUrl()),
// ...
];
}
That redactUrl matters. The donor stored the raw URL, which means a password reset link or a 2FA code passed in a query string would land in the audit table in plain text. The package masks the value of any configured PII key before storing, and (a review note, more on reviews below) it does it without mangling the rest of the URL.
Then the review found it logging the wrong thing
Every module in this series goes through a code review before it merges, and every module so far has had the review catch something real. This one was subtle, because the code looked obviously correct.
The donor had a genuine bug I already knew about: it wrote the same user id into both causer_id and subject_id. "who did it" and "what it was done to" were the same row. That's nonsense for an audit log, so the extraction split them: causer is the actor, subject is the object of the action, resolved separately.
My resolver picked the subject off the event by looking for a known property:
foreach (['subject', 'company', 'model', 'invitation', 'employee'] as $property) {
if (isset($event->{$property}) && $event->{$property} instanceof Model) {
return $event->{$property};
}
}
Looks fine. It passed the test I wrote, which used CompanyCreated (an event that only carries a company). Green. Ship it.
Except the review asked the question my test didn't: what about an event that carries more than one model?
EmployeeRemoved carries both the company and the removed employee. My loop checks company before employee, so for "employee removed" it recorded the company as the subject. The object of the action is the person who got removed, and I was logging the building they got removed from. An auditor reading "employee removed, subject: Acme Co" learns nothing about who was removed.
The fix is one line of ordering, plus the comment so the next person understands why order matters here:
// the most-specific OBJECT of the action wins. EmployeeRemoved carries both
// `company` (context) and `employee` (the actual subject), so specific entities
// must be checked before the surrounding company.
foreach (['subject', 'employee', 'invitation', 'model', 'company'] as $property) {
and a regression test that fires EmployeeRemoved and asserts the subject is the employee, not the company. The lesson, again: the bug wasn't in the line, it was in the test that only exercised the easy case.
A quieter one in the geo path
Geolocation is an outbound call to a third party with the user's IP, so it's opt-out, cached, and runs off a queued job. Private and loopback IPs are answered locally and never leave the server.
The job's failed() handler stamped "Unknown" so a row is never left half-enriched. Reasonable. But the review pointed out: the resolver is fail-soft (it returns "Unknown" itself on any network error and never throws), so the only way the job actually fails is if the database write throws, possibly after a real country was already resolved. In that case failed() would clobber correct geo data with "Unknown". The fix is to only stamp Unknown on a row that was never enriched:
if ($activity->geo_updated_at !== null) {
return; // already enriched; don't overwrite good data with Unknown
}
Small, but it's the kind of thing you only see when someone asks "what is the actual path that reaches this line."
The model-audit trait, fixed
The donor shipped a LogsActivity trait for automatic created/updated/deleted auditing on any model. It was broken in three ways: it overrode spatie's boot method without calling the parent (so spatie's engine never ran), it called a method that didn't exist (fatal on first save), and it wrote every attribute instead of the dirty diff.
The package version throws all that out and uses spatie's native machinery: declare what to log, then decorate the entry with the core's request context through the hook spatie already calls. No engine override, real old-to-new diffs, plus device and IP on every audit row.
What it deliberately is not
Same honesty note I put on every release. v0.5.0 ships the audit log backend and a minimal super-admin viewer. It does not ship the full operator console (statistics, support, billing screens), those arrive with their own phases and slot into the same admin shell. The route-access middleware that logs every request is in the box but off by default, because it's noisy. Domain events (orders shipped, invoices paid) are the host's to register, the core only ships its own auth and tenancy events.
And the model-audit trait writes "HTTP 200, success" on every row, because a model save inside a request is, by definition, a success that reached the database. That's honest for that path, not a status I'm inventing.
Tests, and the close
The package is green: the activity-log suite covers the causer/subject split, the two fixes above, the PII redaction (including the URL-fidelity edge cases the review surfaced), the async geo dispatch and its opt-out, the super-admin gate, and the model-audit diff, plus a frontend test that proves the viewer renders a hostile user-agent as inert text and never as markup. The host integration smoke proves a real company-creation event lands in the log through the live middleware stack.
The pattern that keeps repeating: the extraction is rarely where the bugs are. They show up when a second pass asks the question your own test didn't. "what about the event with two models" found a log that was quietly recording the wrong subject, and it would have sat there looking correct for a long time.
Next up is navigation and the rest of the operator console.
The package is public on GitHub: https://github.com/dmitryisaenko/larafoundry