DEV Community

Dmitry Isaenko
Dmitry Isaenko

Posted on

How I Built a Production-Grade Activity Logging System for a Laravel SaaS (Event-Driven, Zero Manual Calls)

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]);
Enter fullscreen mode Exit fullscreen mode

Problems:

  1. Inconsistent - every developer formats logs differently
  2. Incomplete - someone always forgets to add a log call
  3. Flat - no device info, no geo, no request context
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
    ];
}
Enter fullscreen mode Exit fullscreen mode

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
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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(', '),
    ];
}
Enter fullscreen mode Exit fullscreen mode

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',
    ];
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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'];
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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'];
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
],
Enter fullscreen mode Exit fullscreen mode

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']),
Enter fullscreen mode Exit fullscreen mode

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],
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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.


laravel #php #saas #webdev #larafoundry

Top comments (0)