Introduction
"We'll add logging later."
Every developer has said it. Few actually go back and do it properly. And when they do, it's usually after something breaks in production and nobody can figure out what happened.
When I started building Kohana.io - a production CRM/ERP system - I decided logging would be a Day 1 feature. Not a nice-to-have. Not a "sprint 47" backlog item.
Now I'm extracting the core of Kohana into LaraFoundry - a reusable SaaS engine. And the logging module is one of the most important pieces.
In this article, I'll walk through the complete architecture: the event-driven approach, device fingerprinting, async geolocation, multi-channel notifications, and the admin UI that makes it all actionable.
The Problem with Manual Logging
Most logging in Laravel apps looks like this:
// Scattered across 40 controllers
Log::info('User logged in', ['user_id' => $user->id]);
Log::info('Product created', ['product_id' => $product->id]);
Log::warning('Login failed', ['email' => $email]);
Problems:
- Inconsistent - every developer formats logs differently
- Incomplete - someone always forgets to add a log call
- Flat - no device info, no geo, no request context
- Unqueryable - good luck searching through text files for patterns
LaraFoundry takes a different approach: event-driven, structured, and automatic.
Architecture Overview
User Action (HTTP Request)
|
v
Event Fired (e.g. ProductCreated::class)
|
v
ActivityLogServiceProvider (listener)
|
v
ActivityLogService::logActivity()
|
+---> Creates CustomActivity record
| - Request metadata (IP, URL, method)
| - Device info (browser, OS, device type)
| - Event properties (custom per event)
| - Response status code
|
+---> Dispatches RetrieveActivityGeoData job
|
v
GetGeoDataByIpAction (cached 24h)
|
v
Updates CustomActivity with geo data
Package Stack
| Package | Purpose |
|---|---|
spatie/laravel-activitylog |
Core activity tracking (extended) |
jenssegers/agent |
Device/browser/OS detection |
opcodesio/log-viewer |
Web UI for Monolog file logs |
laravel/telescope |
Dev/testing debugger |
Plus a free geo API (ip-api.com) with smart caching.
Step 1: The Event Map
The heart of the system is ActivityLogServiceProvider. It defines a single array mapping events to log metadata:
class ActivityLogServiceProvider extends ServiceProvider
{
protected array $events = [
// Auth events
Registered::class => ['Auth', 'New user registered', 200],
Login::class => ['Auth', 'Login', 200],
Logout::class => ['Auth', 'Logout', 200],
Failed::class => ['Auth', 'Login failed', 401],
PasswordReset::class => ['Auth', 'Password reset', 200],
// Company events
CompanyCreate::class => ['Company', 'New company created', 200],
CompanyDeleted::class => ['Company', 'Company deleted', 200],
EmployeeInvitationSent::class => ['Company', 'Employee invitation sent', 200],
RoleCreated::class => ['Company', 'Custom role created', 201],
// Warehouse events
ProductCreated::class => ['Warehouse', 'Product created', 201],
ProductUpdated::class => ['Warehouse', 'Product updated', 200],
ProductDeleted::class => ['Warehouse', 'Product deleted', 200],
ProductCreationFailed::class => ['Warehouse', 'Product creation failed', 500],
// Contragent events
ContragentCreated::class => ['Contragent', 'Contragent created', 201],
ContragentDeleted::class => ['Contragent', 'Contragent deleted', 200],
// ... 60+ events total
];
}
Format: [EventGroupName, Description, HTTP ResponseCode].
In the boot() method, all listeners are registered dynamically:
public function boot()
{
Activity::unguard();
foreach ($this->events as $eventClass => [$group, $desc, $code]) {
Event::listen($eventClass, function ($event) use ($eventClass, $group, $desc, $code) {
$eventClassName = class_basename($eventClass);
$eventProperties = method_exists($event, 'getLogProperties')
? ['event_properties' => $event->getLogProperties()]
: [];
ActivityLogService::logActivity(
$event, $eventClassName, $group, $desc, $code, $eventProperties
);
});
}
}
The key insight: each event can optionally implement getLogProperties() to provide module-specific context:
// In TicketCreate event
public function getLogProperties(): array
{
return [
'ticket_id' => $this->ticket->id,
'ticket_title' => $this->ticket->title,
'ticket_priority' => $this->ticket->priority,
'categories' => $this->ticket->categories->pluck('name')->implode(', '),
];
}
No coupling between events and the logging system. Events don't know they're being logged. The ServiceProvider handles everything.
Step 2: The Custom Activity Model
Spatie's default Activity model stores the basics: log_name, description, properties, causer. For a SaaS, that's not enough.
CustomActivity extends Spatie's model with 15 additional fields:
class CustomActivity extends Activity
{
protected $table = 'activity_log';
protected $fillable = [
'user_agent', 'log_name', 'description',
'subject_type', 'subject_id',
'causer_type', 'causer_id', 'properties',
// Device fingerprint
'user_ip', 'user_device_type', 'user_device_name',
'user_os', 'user_browser',
// Request context
'route_name', 'request_method', 'full_url', 'response_code',
// Status
'is_successful', 'user_email',
// Geolocation
'geo_country', 'geo_city', 'geo_updated_at',
];
protected $casts = [
'properties' => 'collection',
'is_successful' => 'boolean',
'geo_updated_at' => 'datetime',
];
}
Now every event is structured and queryable. Want failed logins from a specific country? One Eloquent query.
Step 3: The Activity Log Service
ActivityLogService is the central piece that creates log records and enriches them with device data:
class ActivityLogService
{
public static function logActivity($event, string $eventClassName, string $eventGroupName, string $description, int $responseCode, array $eventProperties = []): void
{
$request = request();
$ip = $request->ip();
$properties = [
'event_group_name' => $eventGroupName,
'middleware' => request()->route()?->gatherMiddleware() ?? [],
] + $eventProperties;
$activity = CustomActivity::create([
'log_name' => $eventClassName,
'description' => $description,
'properties' => $properties,
'response_code' => $responseCode,
'is_successful' => ($responseCode < 400),
'user_ip' => $ip,
'user_device_type' => Agent::isDesktop() ? 'desktop' : (Agent::isTablet() ? 'tablet' : 'mobile'),
'user_device_name' => Agent::device(),
'user_os' => Agent::platform(),
'user_browser' => Agent::browser(),
'route_name' => $request->route()?->getName(),
'request_method' => $request->method(),
'full_url' => $request->fullUrl(),
// Geo filled async
'geo_country' => null,
'geo_city' => null,
]);
RetrieveActivityGeoData::dispatch($activity->id, $ip);
}
}
Notice: geolocation fields are null on creation. They get filled asynchronously via a queued job.
Step 4: Async Geolocation
A geo API call adds ~200ms per request. unacceptable for synchronous execution. LaraFoundry handles this in the background:
class RetrieveActivityGeoData implements ShouldQueue
{
public $timeout = 30;
public $tries = 3;
public function handle()
{
$activity = CustomActivity::find($this->activityId);
if (!$activity) return;
$geoData = app(GetGeoDataByIpAction::class)($this->ip);
$activity->update([
'geo_country' => $geoData['country'],
'geo_city' => $geoData['city'],
'geo_updated_at' => now(),
]);
}
public function failed(\Throwable $exception)
{
Log::error("Failed to get geo data for activity {$this->activityId}: " . $exception->getMessage());
$activity = CustomActivity::find($this->activityId);
$activity?->update([
'geo_country' => 'Unknown',
'geo_city' => 'Unknown',
'geo_updated_at' => now(),
]);
}
}
The GetGeoDataByIpAction itself is a clean, invokable action class with smart caching:
class GetGeoDataByIpAction
{
public function __invoke(string $ip): array
{
if (self::isLocalIp($ip)) {
return ['country' => 'Local network', 'city' => 'Local network'];
}
return Cache::remember("geo_data_{$ip}", 24 * 60 * 60, function () use ($ip) {
try {
$response = Http::timeout(5)->get("http://ip-api.com/json/{$ip}");
if ($response->successful()) {
$data = $response->json();
return [
'country' => $data['country'] ?? 'Unknown',
'city' => $data['city'] ?? 'Unknown',
];
}
} catch (Exception $e) {
Log::warning("Geo API error for IP {$ip}: " . $e->getMessage());
}
return ['country' => 'Unknown', 'city' => 'Unknown'];
});
}
}
Key decisions:
- 24-hour cache per IP (same user, same IP, no redundant API calls)
- Local IP detection (127.0.0.1, 192.168., 10., 172.* skip the API)
- Graceful fallback ("Unknown" if the API is down)
- 5-second timeout (don't hang on slow API responses)
Step 5: Multi-Channel Admin Notifications
Some events need more than a log entry. Admin login attempts in LaraFoundry trigger instant notifications via email and Telegram:
class AdminLoginAttemptNotification extends Notification implements ShouldQueue
{
public function __construct($step)
{
$this->ip = request()->ip();
$geoData = app(GetGeoDataByIpAction::class)($this->ip);
$this->location = 'Country: '.$geoData['country'].', City: '.$geoData['city'];
// Capture device data DURING request, BEFORE queue serialization
$this->deviceType = Agent::isDesktop() ? 'desktop' : (Agent::isTablet() ? 'tablet' : 'mobile');
$this->deviceName = Agent::device();
$this->deviceOS = Agent::platform();
$this->deviceBrowser = Agent::browser();
$this->userAgent = request()->userAgent();
}
public function via($notifiable)
{
return ['mail', 'telegram'];
}
}
Important gotcha: Device data must be captured in the constructor (during the HTTP request), not in the toMail()/toTelegram() methods. The Agent facade needs the actual request context. once the notification is serialized to the queue, that context is gone.
Step 6: File-Based Logging (Monolog)
Activity logs are for business events. For system-level debugging, LaraFoundry uses Monolog with split channels:
// config/logging.php
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'critical'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
],
'critical' => [
'driver' => 'daily',
'path' => storage_path('logs/critical.log'),
'level' => 'error',
'days' => 30,
],
Every log entry goes to daily. Only errors go to critical (with 30-day retention instead of 14). This means you can freely rotate daily logs without losing error history.
For browsing these logs, opcodesio/log-viewer provides a web UI at /admin/log-viewer with search, filtering, and stack trace viewing.
Step 7: Telescope for Development
Laravel Telescope is the developer's microscope. In LaraFoundry, it's enabled only in local and testing:
'enabled' => in_array(env('APP_ENV'), ['local', 'testing']),
Configured watchers:
- QueryWatcher - flags slow queries > 100ms
-
ModelWatcher - tracks all
eloquent.*events - EventWatcher - every event dispatched
- JobWatcher - queue job processing
- ExceptionWatcher - all exceptions
- MailWatcher, NotificationWatcher, GateWatcher, etc.
Zero overhead in production. Full visibility in development.
Step 8: The Admin UI
Logs are useless if nobody looks at them. LaraFoundry provides two Inertia/Vue views:
User-specific logs (/admin/logs/{user}):
- Time range filter: 1h, 6h, 12h, 24h, 48h, 72h
- Device info in tooltips
- Success/error badges
- Responsive: tables on desktop, expandable cards on mobile
General system logs (/admin/generalLogs):
- All events across all users
- Event group filtering (Auth, Company, Warehouse...)
- Response code + result columns
- 100 per page with pagination
The controller is minimal:
public function userLogs(Request $request, User $user)
{
$hours = $request->get('hours', 1);
$logs = CustomActivity::where('causer_id', $user->id)
->where('created_at', '>=', Carbon::now()->subHours($hours))
->orderBy('created_at', 'desc')
->with('user')
->paginate(50);
return Inertia::render('admin/logs/UserLogs', [
'logs' => ActivityLogResource::collection($logs),
'selectedHours' => $hours,
'availableHours' => [1, 6, 12, 24, 48, 72],
]);
}
Testing
Every link in the logging chain gets tested with Pest PHP:
- Events fire correctly from user actions
- ServiceProvider intercepts events and calls ActivityLogService
- CustomActivity records are created with all expected fields
- Device fingerprint data is captured
- Geo-data job dispatches and handles failures
- Admin notifications fire on critical events
- Time range filters return correct datasets
- Log viewer routes require authentication
A logging system that silently breaks is worse than no logging at all.
Summary
| Layer | Tool | Environment | Audience |
|---|---|---|---|
| Business activity | CustomActivity + Inertia UI | All | Admins |
| System debugging | Laravel Telescope | Dev/Testing | Developers |
| File logs | Monolog + Log Viewer | All | DevOps |
| Real-time alerts | Notifications (Mail + Telegram) | All | Admins |
Adding logging to a new module? Add your events to the $events array in ActivityLogServiceProvider. That's it.
If you're building a SaaS and logging isn't a Day 1 feature - reconsider. Your future self debugging a production issue at 2 AM will thank you.
LaraFoundry is a production-proven SaaS engine extracted from Kohana.io. Follow the build-in-public journey and join the waitlist at larafoundry.com.
Top comments (0)