Matthew Hodge
Senior Developer

Build Yourself a Second Brain: An LLM-Maintained Wiki

You want to organise your knowledge into a second brain - somewhere the things you read actually compound instead of rotting in a folder you never reopen. Andrej Karpathy floated a neat pattern for exactly that: instead of dumping everything into a vector database and pulling out chunks at query time, have the LLM build and maintain a wiki - a structured, interlinked set of markdown files that sits between you and your sources. You feed it things; it reads them, writes summaries, cross-links everything, and keeps it tidy. The knowledge compounds instead of evaporating. And because it's just markdown, you can read it, edit it, and version it yourself.


A Few Brains Worth Building

The pattern fits anything you accumulate over time. Karpathy sketches out a handful of contexts in the gist:

  • Personal - tracking your goals, health, psychology and self-improvement. File journal entries, articles and podcast notes, and build a structured picture of yourself over time.
  • Research - going deep on a topic over weeks or months. Read papers, articles and reports, and incrementally build a comprehensive wiki with an evolving thesis.
  • Reading a book - file each chapter as you go and build pages for characters, themes and plot threads, and how they connect. By the end you've got a rich companion wiki - think fan wikis like Tolkien Gateway, except personal, with the LLM doing all the cross-referencing.
  • Business or team - an internal wiki fed by Slack threads, meeting transcripts, project docs and customer calls, with humans reviewing the updates. It stays current because the LLM does the maintenance nobody on the team wants to do.
  • Anything that accumulates - competitive analysis, due diligence, trip planning, course notes, hobby deep-dives.

Here's what I point mine at:

  • Fishing spots - after every trip, log where you went, the conditions and what was biting. The wiki builds a page per spot, per species and per technique, and cross-links them - so "what's worked at the river mouth on a run-out tide in winter" becomes a question you can actually answer.
  • Running - file each run alongside the training articles you read. It tracks your paces and mileage, builds pages for workouts, injuries and races, and turns a year of scattered logs into a clear picture of what actually moves your times.
  • Dev knowledge - probably where it earns its keep most for me. I feed it blog posts and docs across PHP, Laravel, Vue and Docker; it builds entities and concepts, links them together, and answers questions with citations back to the source.

If you'll come back to the knowledge, it's worth building a brain for it.


Setting It Up

The best part is you barely set anything up. You paste Karpathy's gist into an AI coding tool, tell it what your brain is for, and it scaffolds the whole thing. You can use any agentic tool with file access - I use Claude Code in VS Code because it reads a project's CLAUDE.md automatically, which is exactly where the wiki's rules live.

  1. Install Obsidian and create a vault. Obsidian is just a markdown editor, but its [[wikilink]] graph is the whole point - you get a clickable, navigable knowledge graph for free. Hit "Create new vault", point it at an empty folder, and you're done. That folder is your brain.

  2. Open the same folder in VS Code. File → Open Folder, pick the vault. Now you can browse the graph in Obsidian and drive it with an AI agent side by side, both pointed at the same files.

  3. Install the Claude Code extension and connect it. Grab it from the VS Code marketplace and sign in with OAuth - just authorise it against your Claude account, no API key to manage. Prefer a different agent? Anything with filesystem access works - the rest of the steps are identical.

  4. Paste Karpathy's gist with a one-line prompt. Open the gist, copy the whole thing, and drop it into the chat with an instruction that says what your brain is for:

    Read Andrej Karpathy's LLM wiki pattern below and set up this folder for it:
    create the raw/ and wiki/ structure and a CLAUDE.md schema that follows his
    approach. This brain is for tracking everything I'm learning about [your topic].
    
    [paste the full gist here]
    

    Swap that last sentence for your context - a personal brain, a book companion, a research thesis, a team wiki. That one line shapes every categorisation decision the model makes afterwards, so it's worth getting right.

  5. Let it scaffold. It reads the pattern and lays down the folder structure plus a CLAUDE.md - the schema both you and the model follow every session. That's the whole setup. Here's what it just built.


The Three Layers

Once it's scaffolded, this is your entire brain:

my-brain/
├── CLAUDE.md          ← the schema; the model reads this first, every session
├── raw/               ← your sources, dropped in and never edited
└── wiki/
    ├── index.md       ← master catalogue
    ├── log.md         ← append-only history of every change
    ├── overview.md    ← the evolving big picture
    ├── sources/       ← one page per thing you fed it
    ├── entities/      ← people, tools, places, products
    └── concepts/      ← ideas, themes, patterns

Three layers, three owners. You curate raw/ and never touch a file once it's in - that's your ground truth. The model owns everything under wiki/ - the source, entity and concept pages, plus the index and the log, all cross-linked. And you both follow CLAUDE.md, the schema that defines how pages are formatted and what each operation does. The model handles the bookkeeping - the cross-linking, keeping the index current, the tidy-up - which is the part that kills every wiki I've ever tried to keep by hand.


The Commands

Now you just feed it. Drop a source into raw/ - an article, a PDF, a transcript, a chapter - and say "ingest this". The model reads it, tells you what it found, you steer, then it writes and cross-links the pages. That's the whole loop, and you drive all of it in plain language. Three operations cover almost everything:

CommandWhat it's for
IngestRead a new source, talk through the takeaways, then write and cross-link the relevant pages.
QueryAnswer a question from the wiki, with citations back to the source pages, and flag any gaps.
LintAudit the wiki for contradictions, orphan and stub pages, missing pages and stale claims.

One rule worth keeping in your CLAUDE.md: discuss before writing. Tell the model to talk through what it found before it touches any files. Without it, an ingest races off and writes ten pages you didn't want. With it, every ingest is a quick conversation first, then the bookkeeping.

To maintain it, commit after each session - git diff on your wiki is genuinely interesting to scroll, you watch your own understanding take shape - and run a lint now and then. Rot is real, but unlike a folder of notes, your brain can find its own rot when you ask it to.


Final Thoughts

You've now got the whole picture: a second brain that's just a folder of markdown, stood up by the model from Karpathy's gist in a few minutes and maintained by it from there. Three layers, three commands, no vector database, nothing to babysit. You bring the sources and the questions - it does the bookkeeping that quietly kills every other knowledge base.

So go build one. Pick a subject you'll keep circling back to - a stack you're learning, a field you're researching, a hobby you're deep in - and feed it the first thing you read today. Stick with it and you'll end up with something a folder of notes never becomes: a brain that's genuinely yours and gets sharper every time you come back to it.


Laravel Queues in Production: Failed Jobs, Retries, and Monitoring with Horizon

Queues are one of those Laravel features that feel like magic in development and then quietly fall over the first time you put them in front of real traffic. Locally you're probably running the sync driver, so your "queued" jobs run instantly and you never think about them. Then you deploy, switch to a real driver, and discover the harder questions: who's running the worker? What happens when a job throws an exception? How do you even know a job failed?

In this post I'll walk through running Laravel queues properly in production — choosing a driver, keeping workers alive under Supervisor, configuring retries and timeouts so failures are handled gracefully, working the failed_jobs table, and finally putting Horizon on top for real visibility.

If you've used queued event listeners before (implements ShouldQueue), you've already been writing jobs — listeners are just jobs in disguise. This post is about everything that happens after you dispatch one.


A Quick Recap: What a Job Looks Like

A queued job is a class that implements ShouldQueue. Generate one with:

php artisan make:job ProcessOrderShipping

In modern Laravel (11+), the stub is lean — a single Queueable trait pulls in everything:

// app/Jobs/ProcessOrderShipping.php

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ProcessOrderShipping implements ShouldQueue
{
    use Queueable;

    public function __construct(public Order $order) {}

    public function handle(): void
    {
        // Talk to the shipping API, write a label, notify the customer...
    }
}

And you dispatch it from a controller, action, or listener:

ProcessOrderShipping::dispatch($order);

// Or onto a specific queue, with a delay:
ProcessOrderShipping::dispatch($order)
    ->onQueue('shipping')
    ->delay(now()->addMinutes(2));

That's the easy part. The rest of this post is about making sure that job actually runs, and runs reliably.


Choosing a Queue Driver

The driver decides where dispatched jobs are stored until a worker picks them up. You set it in .env via QUEUE_CONNECTION, with the details in config/queue.php.

DriverGood forNotes
syncLocal dev, testsRuns immediately, no queue at all. Never use in production.
databaseSmall apps, no extra infraUses your existing DB. Fine at low volume; adds load as it grows.
redisMost production appsFast, battle-tested, and the only driver Horizon supports.
sqsServerless / managed setupsNo worker servers to babysit, but no Horizon either.
beanstalkdLegacy / specific setupsWorks, but Redis is the more common choice these days.

My default recommendation: Redis. It's fast, you almost certainly already have it around for caching and sessions, and it unlocks Horizon — which is the whole reason this post exists.

If you want to start on database to avoid standing up Redis, that's a perfectly reasonable on-ramp. Laravel 11+ already ships the jobs, failed_jobs, and job_batches migrations in the default skeleton, so you just run:

php artisan migrate

On older versions, generate them first with php artisan queue:table and php artisan queue:failed-table, then migrate.


Running Workers in Production

Here's the thing that trips everyone up the first time: dispatching a job does nothing on its own. Something has to pull jobs off the queue and run them. That something is a worker process:

php artisan queue:work

You'll see queue:listen mentioned in older tutorials. The difference matters:

  • queue:work boots the framework once and stays in memory — fast, and what you want in production.
  • queue:listen reboots the framework on every job — slower, but it picks up code changes without a restart. Handy in development, wasteful in production.

That speed comes with a catch, and it's the single most common queue bug I see: because queue:work holds your code in memory, deploying new code does nothing until you restart the worker. Your shiny bug fix sits in the repo while the old code keeps running. After every deploy, tell workers to finish their current job and gracefully exit:

php artisan queue:restart

Keeping Workers Alive with Supervisor

A worker is just a long-running process, and processes die — out of memory, an unhandled error, a server reboot. In production you never run queue:work by hand; you let a process manager keep it alive. On Linux that's almost always Supervisor (the same tool you'd reach for to keep any long-running process running — if you've containerised a PHP app with Supervisord before, this will feel familiar).

; /etc/supervisor/conf.d/laravel-worker.conf

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=high,default --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopwaitsecs=3600
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log

A few of those flags are doing real work:

  • numprocs=4 runs four workers in parallel. Scale this to your workload and CPU.
  • --queue=high,default processes the high queue before default, so urgent jobs jump the line. Dispatch to it with ->onQueue('high').
  • --max-time=3600 recycles each worker after an hour. Long-lived PHP processes leak memory eventually; restarting on a schedule keeps them honest. --max-jobs=1000 does the same thing by job count.
  • stopwaitsecs=3600 is easy to overlook. When Supervisor restarts a worker it sends a stop signal, then waits this long before force-killing. Set it at least as high as your longest job's timeout, or Supervisor will kill jobs mid-flight during a deploy.

Then reload Supervisor to pick up the config:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*

Handling Failures: Tries, Backoff, and Timeouts

Jobs fail. An API times out, a third party returns a 500, a record gets deleted out from under you. The goal isn't to prevent every failure — it's to fail gracefully and retry sensibly.

By default, if you don't say otherwise, a job is attempted once and then moved to the failed jobs table. That's rarely what you want for anything touching the network. Configure retries right on the job:

// app/Jobs/ProcessOrderShipping.php

class ProcessOrderShipping implements ShouldQueue
{
    use Queueable;

    // Attempt up to 3 times before giving up.
    public int $tries = 3;

    // Wait 10s, then 30s, then 60s between attempts (exponential backoff).
    public array $backoff = [10, 30, 60];

    // Kill the job if a single attempt runs longer than 120 seconds.
    public int $timeout = 120;

    // Stop retrying after 5 unhandled exceptions, even if $tries is higher.
    public int $maxExceptions = 5;

    public function handle(): void
    {
        // ...
    }
}

Backoff matters more than people think. If a downstream API is having a bad moment, hammering it with instant retries makes things worse for everyone. Spacing retries out gives it room to recover.

For time-based retries instead of a count, use retryUntil():

public function retryUntil(): \DateTime
{
    return now()->addMinutes(10);
}

The failed() Hook

When a job exhausts its retries, Laravel calls its failed() method if you've defined one. This is where you do cleanup or raise an alert — mark the order as stuck, notify the team, whatever the situation needs:

use Throwable;

public function failed(Throwable $exception): void
{
    $this->order->update(['shipping_status' => 'failed']);

    Log::error('Shipping job failed for good', [
        'order_id' => $this->order->id,
        'error'    => $exception->getMessage(),
    ]);
}

Make Your Jobs Idempotent

This is the one I'd underline. Retries only help if running a job twice is safe. If your job charges a card or sends an email, a retry after a partial failure can double-charge or double-send. Design jobs so that running them again is harmless — check whether the work is already done before doing it, key external calls on a unique reference, and lean on Laravel's ShouldBeUnique or the WithoutOverlapping middleware where it fits. Idempotency is what makes the whole retry story trustworthy.


Working the Failed Jobs Table

When a job gives up, it lands in failed_jobs with its payload and the exception. A handful of Artisan commands let you work that table:

# See what's failed
php artisan queue:failed

# Retry a specific job by its UUID
php artisan queue:retry 5a3c...e91

# Retry everything that failed
php artisan queue:retry all

# Delete one failed job
php artisan queue:forget 5a3c...e91

# Clear the whole table
php artisan queue:flush

In practice, the useful habit is to check queue:failed as part of your regular health checks rather than waiting for a customer to tell you their order never shipped. Better still — get something to tell you automatically, which is where monitoring comes in.


Monitoring with Horizon

Everything above works, but you're flying blind. You can't see how many jobs are waiting, how long they're taking, or whether your throughput is keeping up with what's being dispatched. Laravel Horizon is the answer — a beautiful dashboard and a smarter worker manager for Redis queues.

Install it:

composer require laravel/horizon
php artisan horizon:install
php artisan migrate

Then, instead of running queue:work yourself, you run Horizon — it manages the worker processes for you based on config/horizon.php:

php artisan horizon

Visit /horizon and you get real-time throughput, runtime and wait-time metrics per queue, a list of recent and failed jobs (with full stack traces), and the ability to retry failed jobs from the browser. The first time you see it, you'll wonder how you managed without it.

Configuring Workers in Horizon

Because Horizon manages workers, your Supervisor config gets simpler — you supervise one process (horizon) instead of a pool of queue:work commands. The worker pool itself is defined in config:

// config/horizon.php

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection'   => 'redis',
            'queue'        => ['high', 'default'],
            'balance'      => 'auto',
            'minProcesses' => 1,
            'maxProcesses' => 10,
            'tries'        => 3,
            'timeout'      => 120,
            'memory'       => 128,
        ],
    ],
],

The balance strategy is Horizon's party trick. With auto, it shifts worker processes between queues based on load — if the high queue suddenly backs up, it moves workers onto it and pulls them back when things calm down. You set the ceiling with maxProcesses and let Horizon allocate within it.

Your Supervisor config then just keeps Horizon alive:

; /etc/supervisor/conf.d/horizon.conf

[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/artisan horizon
autostart=true
autorestart=true
stopwaitsecs=3600
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/horizon.log

And the deploy step changes from queue:restart to:

php artisan horizon:terminate

Same idea — finish current jobs, then exit so Supervisor restarts Horizon with the new code.

Securing the Dashboard

By default the Horizon dashboard is only viewable in local. In production you have to explicitly say who's allowed in, via the viewHorizon gate:

// app/Providers/HorizonServiceProvider.php

protected function gate(): void
{
    Gate::define('viewHorizon', function ($user) {
        return in_array($user->email, [
            'you@example.com',
        ]);
    });
}

Forget this and you've published your job internals — payloads and all — to the public internet. Don't skip it.


Monitoring Without Horizon

Horizon is Redis-only. If you're on SQS or database, you've still got options.

queue:monitor ships with Laravel and fires a QueueBusy event when a queue exceeds a size threshold — hook a notification onto it:

php artisan queue:monitor redis:default,redis:high --max=100

Run it on a schedule and you'll get told when work is piling up.

Laravel Telescope gives you a local/staging dashboard for jobs (among everything else) — great for debugging, though heavier than you'd want recording everything in production.

At minimum, alert on the failed-jobs count. A scheduled command that checks failed_jobs and pings Slack when it's non-empty is ten minutes of work and will save you a bad day.


A Few Production Habits

To wrap up, the things worth doing from day one rather than retrofitting after an incident:

  • Keep payloads small. Pass an ID, not a whole Eloquent model graph — the model gets re-fetched anyway (SerializesModels), and small payloads are faster and less brittle.
  • Separate queues by priority. A slow report shouldn't block a password-reset email. Split them and process the urgent one first.
  • Always set $tries, $timeout, and $backoff explicitly. The defaults are rarely what you want for anything real.
  • Make jobs idempotent. Retries are only safe if a second run can't do damage.
  • Restart workers on deployqueue:restart or horizon:terminate. The number of "my fix isn't live" mysteries this solves is remarkable.
  • Watch the failed count. Whether it's Horizon, queue:monitor, or a cron job, something should tell you when jobs fail — not your users.

Final Thoughts

Queues go from "magic" to "infrastructure" the moment you ship them. The good news is that Laravel gives you everything you need to run them properly: sensible retry handling on the job, Supervisor to keep workers alive, the failed_jobs table as a safety net, and Horizon to turn the whole thing from a black box into a dashboard you actually trust.

If you take one thing away, make it this: assume jobs will fail, design them so retries are safe, and put something in place that tells you when they do. Get those three right and queues become one of the most dependable parts of your stack rather than the scariest. Start with retries and a Supervisor config, add Horizon once you're on Redis, and you'll wonder why you ever ran jobs without visibility.


Using PHP Attributes in Laravel

PHP 8.0 introduced attributes as a native way to add structured metadata to classes, methods, properties, and more. Attributes require PHP 8.0 or higher.

Laravel itself already relies heavily on metadata (routes, middleware, validation rules, policies, etc.), so attributes are a natural fit when you want to attach configuration directly to your code instead of scattering it in arrays or config files.

What are PHP attributes?

Attributes are special metadata that you attach using the #[...] syntax. Under the hood they are just classes marked with the built-in #[Attribute] attribute.

use Attribute;

#[Attribute]
class MyAttribute
{
    public function __construct(public string $value)
    {
    }
}

#[MyAttribute('example')]
class SomeService
{
}

At runtime, you can use PHP's reflection API (e.g. ReflectionClass, ReflectionMethod, ReflectionProperty) to read and act on those attributes.

This is the same mechanism described in the official PHP attributes overview, but here we'll focus specifically on how to use it inside a Laravel app.


Declaring a simple attribute

Let's say we want to mark some controller actions as "internal only", meaning they should only be accessible from within our network or by certain roles.

First, we define a custom attribute class in app/Attributes:

// app/Attributes/InternalOnly.php

namespace App\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
class InternalOnly
{
    public function __construct(
        public ?array $allowedIps = null,
    ) {
    }
}
  • Attribute::TARGET_METHOD means this attribute can only be used on methods.
  • The $allowedIps argument lets you configure per-action IP whitelists if needed.

Using attributes on Laravel controllers

Now we can use this attribute on a controller method:

// app/Http/Controllers/ReportController.php

namespace App\Http\Controllers;

use App\Attributes\InternalOnly;
use Illuminate\Http\Request;

class ReportController extends Controller
{
    #[InternalOnly(["10.0.0.1", "10.0.0.2"])]
    public function index(Request $request)
    {
        // Only internal IPs should reach here
        return view('reports.index');
    }
}

This keeps the configuration (InternalOnly, allowed IPs) right next to the method it affects.

Now we need Laravel to respect this attribute, which is where middleware comes in.


Reading attributes in middleware

We'll create a middleware that uses reflection to check for our InternalOnly attribute on the current controller action.

// app/Http/Middleware/CheckInternalOnly.php

namespace App\Http\Middleware;

use App\Attributes\InternalOnly;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class CheckInternalOnly
{
    public function handle(Request $request, Closure $next)
    {
        $route = $request->route();

        if (! $route) {
            return $next($request);
        }

        $controllerClass = $route->getControllerClass();
        $method          = $route->getActionMethod();

        if (! $controllerClass || ! $method) {
            return $next($request);
        }

        $reflection = new \ReflectionMethod($controllerClass, $method);

        $attributes = $reflection->getAttributes(InternalOnly::class);

        if (empty($attributes)) {
            // No InternalOnly attribute, continue as normal
            return $next($request);
        }

        /** @var InternalOnly $config */
        $config = $attributes[0]->newInstance();

        $clientIp = $request->ip();

        if ($config->allowedIps !== null && ! in_array($clientIp, $config->allowedIps, true)) {
            throw new AccessDeniedHttpException('This endpoint is internal only.');
        }

        return $next($request);
    }
}

Then register the middleware. The location differs between Laravel versions.

Laravel 10 — app/Http/Kernel.php:

protected $routeMiddleware = [
    // ...
    'internal_only' => \App\Http\Middleware\CheckInternalOnly::class,
];

Laravel 11+ — bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'internal_only' => \App\Http\Middleware\CheckInternalOnly::class,
    ]);
})

Finally, apply it to routes that should respect the attribute:

Route::middleware(['internal_only'])
    ->get('/reports', [\App\Http\Controllers\ReportController::class, 'index']);

Now the InternalOnly attribute is actually enforced.

  • If an action has the attribute, the middleware checks the IP.
  • If not, the request continues normally.

Using attributes for console commands and services

Laravel uses Symfony Console under the hood, and modern Symfony allows defining commands using attributes like #[AsCommand]. You can adopt a similar pattern for your own internal tooling.

Example: tagging services with attributes

Let's say you have certain classes that should be auto-registered for a specific process, like scheduled tasks or background workers.

Define a Task attribute:

// app/Attributes/Task.php

namespace App\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
class Task
{
    public function __construct(
        public string $name,
        public ?string $schedule = null,
    ) {
    }
}

Use it on a class:

// app/Tasks/CleanOldReports.php

namespace App\Tasks;

use App\Attributes\Task;

#[Task('clean-old-reports', schedule: '0 3 * * *')]
class CleanOldReports
{
    public function __invoke(): void
    {
        // Clean up old records...
    }
}

In a service provider, you could scan a namespace for classes with the Task attribute and register them dynamically (e.g. into the scheduler or a custom registry).

// app/Providers/TaskServiceProvider.php

namespace App\Providers;

use App\Attributes\Task;
use Illuminate\Support\ServiceProvider;
use ReflectionClass;

class TaskServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $taskClasses = [
            \App\Tasks\CleanOldReports::class,
            // Add more or discover them dynamically
        ];

        foreach ($taskClasses as $taskClass) {
            $reflection = new ReflectionClass($taskClass);

            $attributes = $reflection->getAttributes(Task::class);

            if (empty($attributes)) {
                continue;
            }

            /** @var Task $task */
            $task = $attributes[0]->newInstance();

            // Here you could register the task somewhere, e.g.:
            // app(Schedule::class)->call(new $taskClass())->cron($task->schedule);
            // (The line above is intentionally simplified pseudocode — you'd
            //  need to resolve the Schedule instance and handle any dependencies.)
        }
    }
}

This gives you a declarative way to describe scheduled tasks or background jobs without maintaining big configuration arrays.


When to use attributes in Laravel

Attributes are useful in Laravel when you want to:

  • Attach metadata directly to controllers, models, or services.
  • Build declarative APIs for things like permissions, throttling, logging, or caching.
  • Reduce duplication between configuration arrays and the code they configure.
  • Build your own framework-like features on top of Laravel.

They may not be a good fit when:

  • The configuration is highly dynamic and depends on runtime data (per tenant, per user, etc.).
  • A simple config file or .env variable is clearer.
  • You're adding complexity without a clear benefit.

Final thoughts

PHP attributes give you a powerful, native way to attach metadata directly to your Laravel code. By combining them with Laravel's middleware, service providers, and scheduling, you can build expressive, declarative APIs tailored to your application.

Start small: create a single attribute (like InternalOnly) and wire it into middleware. Once you're comfortable with the pattern, you'll find many opportunities to use attributes to replace scattered configuration arrays and make your Laravel codebase clearer and easier to maintain.


Building AI Agents in Laravel with Prism

AI features have gone from "interesting experiment" to something clients are genuinely asking for. The challenge as a Laravel developer has been that the official SDKs from Anthropic, OpenAI, and others are PHP-first but not Laravel-first — you end up writing a lot of plumbing yourself.

Prism solves this. It's a Laravel-native AI SDK that wraps multiple providers (Anthropic, OpenAI, Gemini, Mistral, and others) behind a consistent, fluent API. Provider swaps are a config change, tool-use and agents work the same way regardless of which model you're using, and it slots into Laravel's service container naturally.

In this post, I'll walk through installing Prism, generating text, working with structured output, and building a practical agent with tools.


Installation

composer require echolabs/prism
php artisan vendor:publish --tag=prism-config

This creates config/prism.php. Add your provider credentials to .env:

ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...

Configure your default provider and model in config/prism.php:

'default_provider' => env('PRISM_PROVIDER', 'anthropic'),
'default_model'    => env('PRISM_MODEL', 'claude-sonnet-4-6'),

Simple Text Generation

The most basic use case — generate text from a prompt:

use EchoLabs\Prism\Facades\Prism;
use EchoLabs\Prism\Enums\Provider;

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withPrompt('Explain what a Laravel service provider does in two sentences.')
    ->generate();

echo $response->text;

Or using your configured defaults:

$response = Prism::text()
    ->withPrompt('What is the difference between a Job and an Event in Laravel?')
    ->generate();

echo $response->text;

System Prompts and Messages

For anything beyond a single prompt, you'll want a system message and a message history. This is how you shape the model's persona or context:

use EchoLabs\Prism\ValueObjects\Messages\UserMessage;
use EchoLabs\Prism\ValueObjects\Messages\AssistantMessage;

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withSystemPrompt('You are a helpful assistant specialising in Laravel and PHP. Keep responses concise and practical.')
    ->withMessages([
        new UserMessage('How do I eager load relationships in Laravel?'),
        new AssistantMessage('Use `with()` on your query: `User::with("posts")->get()`...'),
        new UserMessage('What about nested relationships?'),
    ])
    ->generate();

echo $response->text;

This is the foundation of building a multi-turn chat feature.


Structured Output

Instead of parsing plain text, you can tell the model to return structured data matching a schema. Prism handles the JSON extraction and validation:

use EchoLabs\Prism\Schema\ObjectSchema;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\ArraySchema;

$schema = new ObjectSchema(
    name: 'ticket_triage',
    description: 'Triage result for a support ticket',
    properties: [
        new StringSchema('priority', 'Priority level: low, medium, high, or critical'),
        new StringSchema('category', 'Issue category: billing, technical, account, or other'),
        new StringSchema('summary', 'One-sentence summary of the issue'),
        new ArraySchema('tags', 'Relevant tags', new StringSchema('tag', 'A tag')),
    ],
    requiredFields: ['priority', 'category', 'summary']
);

$response = Prism::structured()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withSystemPrompt('You are a support ticket triage assistant.')
    ->withPrompt('Ticket: "I can\'t log in, it says my password is wrong but I just reset it 5 minutes ago."')
    ->withSchema($schema)
    ->generate();

$result = $response->structured;
// [
//     'priority' => 'high',
//     'category' => 'account',
//     'summary'  => 'User cannot log in despite recently resetting their password.',
//     'tags'     => ['authentication', 'password-reset', 'login'],
// ]

You get a typed, validated array back — ready to store in the database or act on directly.


Building an Agent with Tools

This is where Prism gets genuinely interesting. Agents are models that can call tools (PHP functions you define) to take actions or retrieve information, then use the results to continue reasoning.

Defining Tools

use EchoLabs\Prism\Tool;

$getOrderStatus = Tool::as('get_order_status')
    ->for('Retrieve the current status of a customer order by order ID')
    ->withStringParameter('order_id', 'The order ID to look up')
    ->using(function (string $order_id): string {
        $order = Order::find($order_id);

        if (! $order) {
            return "Order {$order_id} not found.";
        }

        return "Order {$order_id}: status={$order->status}, placed={$order->created_at->toDateString()}, total=\${$order->total}";
    });

$getCustomerOrders = Tool::as('get_customer_orders')
    ->for('List recent orders for a customer by their email address')
    ->withStringParameter('email', 'The customer email address')
    ->using(function (string $email): string {
        $customer = Customer::where('email', $email)->first();

        if (! $customer) {
            return "No customer found with email {$email}.";
        }

        $orders = $customer->orders()->latest()->limit(5)->get();

        if ($orders->isEmpty()) {
            return "Customer {$email} has no orders.";
        }

        return $orders->map(fn($o) => "#{$o->id} - {$o->status} - \${$o->total} - {$o->created_at->toDateString()}")->join("\n");
    });

Running the Agent

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withSystemPrompt('You are a customer support agent for an e-commerce store. Use the tools available to look up order and customer information before answering questions.')
    ->withPrompt('Can you check on my recent orders? My email is jane@example.com')
    ->withTools([$getOrderStatus, $getCustomerOrders])
    ->withMaxSteps(5)
    ->generate();

echo $response->text;
// "I found your recent orders, Jane! Here's what I can see:
//  - Order #1042 — completed — $89.99 — March 10
//  - Order #1038 — shipped — $234.00 — February 28
//  ..."

withMaxSteps controls how many tool call rounds the agent can make. The model calls a tool, gets the result, decides whether to call another tool or respond to the user, and keeps going until it has an answer or hits the step limit.


A Practical Example: Article Tag Suggester

Here's a complete, practical example — a class that takes a blog post body and suggests tags using structured output:

// app/AI/ArticleTagger.php

namespace App\AI;

use EchoLabs\Prism\Facades\Prism;
use EchoLabs\Prism\Enums\Provider;
use EchoLabs\Prism\Schema\ObjectSchema;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\ArraySchema;

class ArticleTagger
{
    private ObjectSchema $schema;

    public function __construct()
    {
        $this->schema = new ObjectSchema(
            name: 'article_tags',
            description: 'Suggested tags and metadata for a blog article',
            properties: [
                new ArraySchema('tags', 'Suggested tags (3-6)', new StringSchema('tag', 'A tag')),
                new StringSchema('primary_category', 'The primary technical category'),
                new StringSchema('difficulty', 'Difficulty level: beginner, intermediate, or advanced'),
            ],
            requiredFields: ['tags', 'primary_category', 'difficulty']
        );
    }

    public function suggest(string $title, string $body): array
    {
        $response = Prism::structured()
            ->using(Provider::Anthropic, 'claude-haiku-4-5-20251001') // cheaper model for this task
            ->withSystemPrompt('You are a technical blog editor. Analyse the article and return appropriate tags and metadata.')
            ->withPrompt("Title: {$title}\n\n{$body}")
            ->withSchema($this->schema)
            ->generate();

        return $response->structured;
    }
}

Use it in a controller or action:

$tagger  = new ArticleTagger();
$result  = $tagger->suggest($post->title, $post->body);

$post->update([
    'tags'     => $result['tags'],
    'category' => $result['primary_category'],
]);

Token Usage and Cost Tracking

Prism exposes token usage so you can track costs:

$response = Prism::text()
    ->withPrompt('Summarise this article...')
    ->generate();

$usage = $response->usage;

Log::info('AI usage', [
    'input_tokens'  => $usage->promptTokens,
    'output_tokens' => $usage->completionTokens,
]);

Store this in a database table if you want per-user or per-feature cost reporting.


Swapping Providers

One of Prism's best features is that your application code doesn't change when you swap providers. If you want to test OpenAI vs Anthropic, change the provider in the using() call — the rest stays the same:

// Was: Provider::Anthropic, 'claude-sonnet-4-6'
// Now:
->using(Provider::OpenAI, 'gpt-4o')

Or make it a config value and switch via .env:

->using(
    config('prism.default_provider'),
    config('prism.default_model')
)

Final Thoughts

Prism lowers the barrier to adding AI features to a Laravel app significantly. The fluent API is familiar, tools make agent-style features practical without a lot of boilerplate, and switching providers is trivial.

Start with something small — a summariser, a tag suggester, a support ticket classifier — and get comfortable with the request/response cycle. Once you've got that pattern down, building more sophisticated agents with multiple tools is a natural extension.

The Laravel and AI space is moving fast, so keep an eye on the Prism docs for new provider support and features.


Tailwind CSS Dark Mode: Class Strategy with Vue

Dark mode has gone from a nice-to-have to something users genuinely expect. Tailwind makes the styling side easy — you just prefix utilities with dark: — but wiring up the toggle, persisting the user's preference, and avoiding a flash of the wrong theme on load takes a bit of thought.

In this post, I'll cover Tailwind's class strategy for dark mode, build a persistent toggle with a Vue composable, and touch on setting up a sensible custom colour palette for both modes.


The Two Strategies

Tailwind offers two dark mode strategies:

  • media — automatically applies dark styles based on the OS preference (prefers-color-scheme). Simple, zero JavaScript.
  • class — applies dark styles when a dark class is present on a parent element (usually <html>). Lets users toggle manually.

For most apps where you want a toggle, class is the right call. Set it in tailwind.config.js:

// tailwind.config.js

export default {
    darkMode: 'class',
    content: [
        './components/**/*.{vue,js}',
        './pages/**/*.vue',
        './layouts/**/*.vue',
        './app.vue',
    ],
    theme: {
        extend: {},
    },
}

Now any dark: variant will activate when <html class="dark">.


Writing Dark Mode Classes

With the class strategy enabled, you write both modes side by side:

<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
    <p class="text-gray-600 dark:text-gray-400">
        Some body text here.
    </p>
    <button class="bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400">
        Click me
    </button>
</div>

The light styles are your defaults, the dark: prefixed ones kick in when html.dark is present.


A useDarkMode Composable

Rather than toggling the class in every component that has a theme button, pull it into a composable. This also handles reading and persisting the user's preference.

// composables/useDarkMode.js

import { ref, watch, onMounted } from 'vue'

const isDark = ref(false)

export function useDarkMode() {
    function applyTheme(dark) {
        if (dark) {
            document.documentElement.classList.add('dark')
        } else {
            document.documentElement.classList.remove('dark')
        }
    }

    function toggle() {
        isDark.value = ! isDark.value
    }

    watch(isDark, (newValue) => {
        applyTheme(newValue)
        localStorage.setItem('theme', newValue ? 'dark' : 'light')
    })

    onMounted(() => {
        const saved = localStorage.getItem('theme')
        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches

        // Saved preference wins, then fall back to OS preference
        isDark.value = saved ? saved === 'dark' : prefersDark
        applyTheme(isDark.value)
    })

    return { isDark, toggle }
}

Use it in a toggle component:

<!-- components/ThemeToggle.vue -->

<script setup>
import { useDarkMode } from '@/composables/useDarkMode'

const { isDark, toggle } = useDarkMode()
</script>

<template>
    <button
        @click="toggle"
        class="p-2 rounded-lg transition-colors
               text-gray-600 hover:text-gray-900 hover:bg-gray-100
               dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800"
        :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
    >
        <!-- Sun icon -->
        <svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z" />
        </svg>
        <!-- Moon icon -->
        <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
        </svg>
    </button>
</template>

Drop <ThemeToggle /> anywhere in your layout and it works.


Avoiding the Flash on Load

The flash of the wrong theme (FODT) happens because the page renders before JavaScript loads and sets the class. The fix is an inline script in the <head> that runs synchronously — before anything else paints.

In a Nuxt project, add it via useHead in your app.vue or via a plugin:

<!-- app.vue -->

<script setup>
useHead({
    script: [
        {
            innerHTML: `
                (function() {
                    const saved = localStorage.getItem('theme');
                    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
                    if (saved === 'dark' || (!saved && prefersDark)) {
                        document.documentElement.classList.add('dark');
                    }
                })();
            `,
            type: 'text/javascript',
        },
    ],
})
</script>

Because this is inline and in the <head>, it runs before the browser paints the first frame. No flash.

For a standard Vue project with a plain HTML file:

<!DOCTYPE html>
<html lang="en">
<head>
    <script>
        (function() {
            const saved = localStorage.getItem('theme');
            const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
            if (saved === 'dark' || (!saved && prefersDark)) {
                document.documentElement.classList.add('dark');
            }
        })();
    </script>
    <!-- rest of head -->
</head>

A Custom Colour Palette

Rather than sprinkling bg-white dark:bg-gray-900 everywhere, define semantic colour tokens in your Tailwind config that you use throughout your components. This way, changing your dark mode background means one edit, not a find-and-replace across the whole codebase.

// tailwind.config.js

export default {
    darkMode: 'class',
    theme: {
        extend: {
            colors: {
                surface: {
                    DEFAULT: '#ffffff',
                    dark:    '#111827',
                },
                'surface-raised': {
                    DEFAULT: '#f9fafb',
                    dark:    '#1f2937',
                },
                content: {
                    DEFAULT: '#111827',
                    dark:    '#f9fafb',
                },
                muted: {
                    DEFAULT: '#6b7280',
                    dark:    '#9ca3af',
                },
            },
        },
    },
}

Then your markup becomes:

<div class="bg-surface dark:bg-surface-dark text-content dark:text-content-dark">
    <p class="text-muted dark:text-muted-dark">Secondary text</p>
</div>

Still verbose, but now each token has a clear purpose and you can retheme by changing one place.


Tips

  • Test both modes from the start — it's much harder to retrofit dark mode on a component that was designed light-only.
  • Check contrast in both modes. Tools like the Tailwind colour palette or browser accessibility tools will flag low-contrast text.
  • Use CSS variables if your palette gets complex — Tailwind works well with var(--color-surface) style tokens defined in :root and html.dark.
  • Watch for third-party components — UI libraries may not respect your dark class. Check their dark mode docs before committing.

Final Thoughts

The class strategy is a bit more work than media-only dark mode, but the extra control is worth it. Users expect to be able to set their preference independently of the OS, and they definitely expect it to be remembered.

Once you've got the composable and the anti-flash script in place, the rest is just writing dark: variants alongside your normal classes — and that part is genuinely painless with Tailwind.