Simple AuditLog To Tracked Your Database Using Laravel Service Provider

INTRO

Keeping track of what actually happens inside your Laravel app shouldn’t feel like detective work, bro. Whether a user updated a post, deleted a comment, or changed some important setting at 3AM — you deserve to know who did what and when it happened without digging through logs like a caveman. That’s where a simple, clean, no-BS AuditLog comes in. In this guide, we’ll build a lightweight audit tracking system using a Laravel Service Provider — something you can drop into any project to monitor database changes without the headache. Grunt-approved, developer-friendly, and zero over-engineering. Let’s roll.

The Migration

Before our AuditLog system can actually track anything, we need a reliable place to store all the activity data. This is where the migration comes in. In Laravel, migrations let you version-control your database structure, and for an audit trail feature, having a well-designed table is crucial. We want something lightweight, easy to query, and flexible enough to log different types of actions — from a user updating their profile to an admin deleting a record at 2AM.

So in this step, we’ll create an audit_logs table that captures the essentials: which user performed the action, what model or record they interacted with, what the action was, and some optional context like IP address, user agent, and session ID. These small pieces of data help you understand the “who, what, where” of your application — which is exactly what an audit log is supposed to do.

The goal here is not to over-engineer anything. We’re building a clean Laravel migration that stays friendly for beginners but still powerful enough for production apps. Once this table is in place, everything else — the Service Provider, the event listeners, the logger class — will plug into it smoothly. Below is the simplified migration structure that forms the foundation of our AuditLog system:

1. Step one

Before we can log anything in our Laravel AuditLog system, we need a proper database table that will store every action happening inside our app. Think of this table as the “black box recorder” of your Laravel project — every update, delete, or important change gets written here automatically.

Laravel makes this super easy thanks to migrations and models. All we need to do is generate a new AuditLog model along with its migration and resource controller. This gives us the full structure to record actions, query logs, and manage everything cleanly from one place.

To kick things off, we’ll generate everything using a single Artisan command. This sets up:

  • The model (AuditLog)
  • The migration (database table)
  • The resource controller
  • The factory (if needed)
  • The seeder (optional)
  • And a basic directory structure to keep it all clean

Once this is in place, we’ll fill the migration with all the fields our logging system needs. These fields capture the essentials like: which user did something, what model they touched, what actually happened, the IP address, the user agent, and even how many times an action was attempted.

This gives us a solid foundation for the rest of the AuditLog service provider and its automatic event listeners.

                                php artisan make:model AuditLog -rms

                            

This command does three things:

  • -r → creates a Resource Controller
  • -m → creates a Migration
  • -s → creates a Seeder

Perfect starter setup for a clean audit log.

Required Fields for the AuditLog Migration

Inside your migration file (create_audit_logs_table.php), you’ll want the following columns:

Once Migration Is created Copy and paste this Inside the migration

                                public function up()
{
    Schema::create('audit_logs', function (Blueprint $table) {
        $table->id();

        $table->unsignedBigInteger('user_id')->nullable(); 
        $table->string('action');
        $table->string('model_type')->nullable();
        $table->unsignedBigInteger('model_id')->nullable();
        $table->text('details')->nullable();
        $table->string('ip')->nullable();
        $table->string('user_agent')->nullable();
        $table->string('status')->nullable();
        $table->string('session_id')->nullable();
        $table->unsignedInteger('attempt_count')->default(1);

        $table->timestamps();
        $table->engine = 'InnoDB';
    });

    Schema::table('audit_logs', function (Blueprint $table) {
        $table->foreign('user_id')
              ->references('id')
              ->on('users')
              ->onDelete('set null');
    });
}

                            

and Copy this code inside the AuditLog model

                                <?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class AuditLog extends Model
{
    use HasFactory;

    protected $table = 'audit_logs';

    /**
     * Fields we allow mass assignment on.
     */
    protected $fillable = [
        'user_id',
        'action',
        'model_type',
        'model_id',
        'details',
        'ip',
        'user_agent',
        'status',
        'session_id',
        'attempt_count',
    ];

    /**
     * Casting certain fields to proper types.
     */
    protected $casts = [
        'details' => 'array',
        'attempt_count' => 'integer',
    ];

    /**
     * Relationship: AuditLog belongs to a User.
     */
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * Optional helper method for quick logging anywhere.
     */
    public static function record(array $data)
    {
        return self::create([
            'user_id'      => $data['user_id'] ?? auth()->id(),
            'action'       => $data['action'] ?? 'unknown',
            'model_type'   => $data['model_type'] ?? null,
            'model_id'     => $data['model_id'] ?? null,
            'details'      => $data['details'] ?? null,
            'ip'           => request()->ip(),
            'user_agent'   => request()->userAgent(),
            'status'       => $data['status'] ?? 'success',
            'session_id'   => session()->getId(),
            'attempt_count'=> $data['attempt_count'] ?? 1,
        ]);
    }
}

                            

once everything is done lets fill up our controller 

                                <?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\Request;

class AuditLogController extends Controller
{
    /**
     * Display a listing of the audit logs.
     */
    public function index(Request $request)
    {
        $query = AuditLog::query();

        // Optional filters
        if ($request->filled('action')) {
            $query->where('action', $request->action);
        }

        if ($request->filled('user_id')) {
            $query->where('user_id', $request->user_id);
        }

        if ($request->filled('model_type')) {
            $query->where('model_type', $request->model_type);
        }

        if ($request->filled('status')) {
            $query->where('status', $request->status);
        }

        if ($request->filled('search')) {
            $search = $request->search;

            $query->where(function ($q) use ($search) {
                $q->where('details', 'LIKE', "%{$search}%")
                  ->orWhere('action', 'LIKE', "%{$search}%")
                  ->orWhere('ip', 'LIKE', "%{$search}%")
                  ->orWhere('user_agent', 'LIKE', "%{$search}%");
            });
        }

        $logs = $query->latest()->paginate(25);

        return view('admin.audit_logs.index', compact('logs'));
    }

    /**
     * Display a single audit log record.
     */
    public function show(AuditLog $auditLog)
    {
        return view('admin.audit_logs.show', compact('auditLog'));
    }

    /**
     * Store a newly created audit log entry.
     * (Usually called from service or event listeners)
     */
    public function store(Request $request)
    {
        $data = $request->validate([
            'user_id'       => 'nullable|integer',
            'action'        => 'required|string|max:255',
            'model_type'    => 'nullable|string|max:255',
            'model_id'      => 'nullable|integer',
            'details'       => 'nullable|string',
            'ip'            => 'nullable|string|max:45',
            'user_agent'    => 'nullable|string',
            'status'        => 'nullable|string|max:50',
            'session_id'    => 'nullable|string|max:255',
            'attempt_count' => 'nullable|integer',
        ]);

        AuditLog::create($data);

        return response()->json(['message' => 'Audit log recorded']);
    }

    /**
     * Delete a single audit log entry.
     */
    public function destroy(AuditLog $auditLog)
    {
        $auditLog->delete();

        return redirect()
            ->back()
            ->with('success', 'Audit log deleted successfully');
    }

    /**
     * Clear all audit logs (dangerous!)
     */
    public function clearAll()
    {
        AuditLog::truncate();

        return redirect()
            ->back()
            ->with('success', 'All audit logs have been cleared!');
    }
}

                            

PHASE 2

Now Everything Is set the final phase is to make an Observer by using this simple command

                                php artisan make:observer AuditLogObserver --model=AuditLog

                            

once the observer is made Lets fill it up with this

                                <?php

namespace App\Observers;

use App\Models\AuditLog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;

class AuditLogObserver
{
    /**
     * Handle model "created" event.
     */
    public function created(Model $model): void
    {
        $this->record($model, 'created');
    }

    /**
     * Handle model "updated" event.
     */
    public function updated(Model $model): void
    {
        $this->record($model, 'updated', [
            'changes' => $model->getChanges(),
            'original' => $model->getOriginal(),
        ]);
    }

    /**
     * Handle model "deleted" event.
     */
    public function deleted(Model $model): void
    {
        $this->record($model, 'deleted');
    }

    /**
     * Handle model "restored" event.
     */
    public function restored(Model $model): void
    {
        $this->record($model, 'restored');
    }

    /**
     * Handle model "force deleted" event.
     */
    public function forceDeleted(Model $model): void
    {
        $this->record($model, 'force_deleted');
    }

    /**
     * Write entry to audit_logs.
     */
    protected function record(Model $model, string $action, array $extraDetails = []): void
    {
        try {
            AuditLog::create([
                'user_id'      => Auth::id(),
                'action'       => $action,
                'model_type'   => get_class($model),
                'model_id'     => $model->getKey(),
                'details'      => json_encode($extraDetails, JSON_PRETTY_PRINT),
                'ip'           => Request::ip(),
                'user_agent'   => Request::header('User-Agent'),
                'status'       => 'success',
                'session_id'   => session()->getId(),
                'attempt_count'=> 1,
            ]);
        } catch (\Throwable $e) {
            // Prevent infinite loop & avoid breaking user actions
            \Log::error("AuditLogObserver failed: " . $e->getMessage());
        }
    }
}

                            

CONCLLUSION

have a complete backend pipeline: a database table to store logs, a fully working AuditLog model, and a powerful Observer that automatically records every create, update, delete, and restore action inside your application. This foundation is solid enough for real-world apps — lightweight, zero dependencies, and fully extendable.

But we’re just warming up.

In the next part of this tutorial series, we’re going to take these raw log entries and turn them into something actually useful for humans. That means building a clean, searchable, filterable AuditLog Viewer inside your Laravel app. You’ll be able to:

  • 📄 See all activity happening across your app
  • 🔍 Filter logs by user, model, action, or date
  • ✏️ Inspect details like IP address, user agent, session ID
  • 🗑️ Delete logs you no longer need
  • ♻️ Update or correct entries when required
  • ⚡ Add pagination, sorting, and a developer-friendly UI

Basically, we’re leveling up from “we store logs” to “we actually understand what’s going on in our system.”

So stay tuned — Phase Two is where we bring everything together visually. This is where your audit system goes from a silent background worker to a full-blown admin dashboard feature.

Grab a coffee, save your progress, and get ready. Next tutorial: Building the AuditLog Viewer (UI + Controller + Routes + Filters)