Matthew Hodge
Full Stack Developer

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.


Bash Scripts Every Developer Should Have

Most developers spend a surprising amount of time on repetitive terminal tasks — switching between projects, running the same sequence of commands, tailing logs, deploying. A few well-placed Bash scripts can cut that friction significantly.

This isn't a Bash tutorial — it's a collection of scripts I actually use, with enough explanation that you can adapt them for your own workflow.


1. Project Switcher

If you're jumping between multiple projects throughout the day, this saves you from repeatedly navigating to directories and starting services.

#!/usr/bin/env bash
# ~/bin/project

PROJECTS_DIR="$HOME/Websites"

projects=(
    "matthew-blog:$PROJECTS_DIR/matthew-blog"
    "client-app:$PROJECTS_DIR/client-app"
    "api:$PROJECTS_DIR/api"
)

if [ -z "$1" ]; then
    echo "Available projects:"
    for project in "${projects[@]}"; do
        echo "  ${project%%:*}"
    done
    exit 0
fi

for project in "${projects[@]}"; do
    name="${project%%:*}"
    path="${project##*:}"

    if [ "$name" = "$1" ]; then
        cd "$path" || exit 1
        echo "Switched to $name ($path)"

        # Start any services you need — comment out what you don't
        # docker compose up -d 2>/dev/null
        # code .

        exec $SHELL
    fi
done

echo "Project '$1' not found."
exit 1

Make it executable and put it on your PATH:

chmod +x ~/bin/project
# Ensure ~/bin is in your PATH in ~/.bashrc or ~/.zshrc:
# export PATH="$HOME/bin:$PATH"

Usage:

project               # list all
project matthew-blog  # switch to it

2. Git Branch Cleanup

After a few weeks of active development, you end up with dozens of stale local branches. This script deletes all local branches that have already been merged into main (or whatever your default branch is).

#!/usr/bin/env bash
# ~/bin/git-clean

DEFAULT_BRANCH="${1:-main}"

echo "Fetching latest from remote..."
git fetch --prune

merged=$(git branch --merged "$DEFAULT_BRANCH" | grep -v "^\*" | grep -v "$DEFAULT_BRANCH" | tr -d ' ')

if [ -z "$merged" ]; then
    echo "No merged branches to clean up."
    exit 0
fi

echo "Branches to delete:"
echo "$merged"
echo ""
read -rp "Delete these branches? [y/N] " confirm

if [[ "$confirm" =~ ^[Yy]$ ]]; then
    echo "$merged" | xargs git branch -d
    echo "Done."
else
    echo "Aborted."
fi
git-clean        # cleans branches merged into main
git-clean dev    # cleans branches merged into dev

The git fetch --prune at the top removes references to remote branches that no longer exist, so your git branch -r output doesn't get cluttered either.


3. New Laravel Project Bootstrap

Starting a new Laravel project always involves the same steps. This script handles them all:

#!/usr/bin/env bash
# ~/bin/new-laravel

if [ -z "$1" ]; then
    echo "Usage: new-laravel <project-name>"
    exit 1
fi

PROJECT_NAME="$1"
PROJECTS_DIR="$HOME/Websites"
PROJECT_PATH="$PROJECTS_DIR/$PROJECT_NAME"

echo "Creating Laravel project: $PROJECT_NAME"

composer create-project laravel/laravel "$PROJECT_PATH"
cd "$PROJECT_PATH" || exit 1

# Copy .env
cp .env.example .env

# Set DB name to project name (replace hyphens with underscores)
DB_NAME="${PROJECT_NAME//-/_}"
sed -i "s/DB_DATABASE=laravel/DB_DATABASE=$DB_NAME/" .env

php artisan key:generate

# Init git
git init
git add -A
git commit -m "Initial Laravel install"

echo ""
echo "Done! Project created at $PROJECT_PATH"
echo "Next: cd $PROJECT_PATH && php artisan serve"

4. Log Watcher

Tailing logs is fine, but this adds colour-coding so errors jump out at you:

#!/usr/bin/env bash
# ~/bin/watch-log

LOG_FILE="${1:-storage/logs/laravel.log}"

if [ ! -f "$LOG_FILE" ]; then
    echo "Log file not found: $LOG_FILE"
    exit 1
fi

tail -f "$LOG_FILE" | while read -r line; do
    if echo "$line" | grep -q "ERROR\|CRITICAL\|EMERGENCY"; then
        echo -e "\033[31m$line\033[0m"   # Red
    elif echo "$line" | grep -q "WARNING"; then
        echo -e "\033[33m$line\033[0m"   # Yellow
    elif echo "$line" | grep -q "INFO"; then
        echo -e "\033[32m$line\033[0m"   # Green
    else
        echo "$line"
    fi
done

Run it from your project root:

watch-log                               # default Laravel log
watch-log /var/log/nginx/error.log      # any log file

5. Quick Git Stats

A summary of what's happened in a repo lately — useful when coming back to a project after time away:

#!/usr/bin/env bash
# ~/bin/git-stats

DAYS="${1:-7}"

echo "=== Git activity for the last $DAYS days ==="
echo ""

echo "--- Commits ---"
git log --oneline --since="$DAYS days ago"

echo ""
echo "--- Files changed ---"
git diff --stat "HEAD@{$DAYS days ago}" HEAD 2>/dev/null | tail -1

echo ""
echo "--- Contributors ---"
git shortlog -sn --since="$DAYS days ago"
git-stats      # last 7 days
git-stats 30   # last 30 days

6. Simple Deploy Script

This is a minimal deploy script for projects hosted on a VPS where you SSH in and pull. Adapt it for your own setup:

#!/usr/bin/env bash
# deploy.sh (in project root)

set -e  # exit immediately on error

SERVER_USER="ubuntu"
SERVER_HOST="your.server.com"
PROJECT_PATH="/var/www/your-app"

echo "Deploying to $SERVER_HOST..."

ssh "$SERVER_USER@$SERVER_HOST" << 'REMOTE'
    set -e
    cd /var/www/your-app

    echo "Pulling latest..."
    git pull origin main

    echo "Installing dependencies..."
    composer install --no-dev --optimize-autoloader

    echo "Running migrations..."
    php artisan migrate --force

    echo "Clearing caches..."
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache

    echo "Restarting queue workers..."
    php artisan queue:restart

    echo "Done."
REMOTE

echo "Deployment complete."

The set -e at the top is important — if any command fails (migration error, composer fail), the script stops immediately rather than blundering forward.


Making Scripts Available Everywhere

Put your scripts in ~/bin/ and add it to your PATH in ~/.bashrc or ~/.zshrc:

export PATH="$HOME/bin:$PATH"

Then source ~/.bashrc (or restart your terminal) and they're available from anywhere.

A few other tips:

  • Always add set -e to scripts where a failure partway through would leave things in a broken state.
  • Use set -u to catch references to undefined variables — catches a lot of typo bugs.
  • set -o pipefail makes pipeline errors propagate correctly (without it, failing_command | grep something exits 0).
  • Add usage messages at the top of every script — future-you will appreciate it.

Final Thoughts

The best Bash scripts are the ones that automate something you'd otherwise do manually three times a week. Start by looking at your own command history — history | sort | uniq -c | sort -rn | head -20 — and see what comes up repeatedly. That's your automation backlog.


Nuxt 3 Server Routes and API Endpoints

One of the underrated features of Nuxt 3 is the built-in server engine, Nitro. You can build a full API right inside your Nuxt project without reaching for a separate Express or Laravel backend — and for a lot of projects, that's all you need.

In this post, I'll walk through how server routes work, covering GET and POST handlers, reading request bodies, middleware, and connecting to a database.


How Server Routes Work

Any file you drop inside server/api/ becomes an API endpoint automatically — no router configuration needed. The file name maps to the route, and the HTTP method comes from the filename suffix.

server/
└── api/
    ├── users/
    │   ├── index.get.ts        → GET  /api/users
    │   ├── index.post.ts       → POST /api/users
    │   └── [id].get.ts         → GET  /api/users/:id
    └── health.get.ts           → GET  /api/health

Each file exports a default handler function using defineEventHandler.


A Simple GET Endpoint

// server/api/health.get.ts

export default defineEventHandler(() => {
    return {
        status: 'ok',
        timestamp: new Date().toISOString(),
    }
})

Hit http://localhost:3000/api/health and you get:

{
    "status": "ok",
    "timestamp": "2025-12-03T10:00:00.000Z"
}

Nitro automatically serialises the returned object as JSON with the right content-type header. No res.json(), no boilerplate.


Reading Route Parameters

// server/api/users/[id].get.ts

export default defineEventHandler(async (event) => {
    const id = getRouterParam(event, 'id')

    // In a real app, query your database here
    const user = await getUserById(Number(id))

    if (! user) {
        throw createError({
            statusCode: 404,
            statusMessage: 'User not found',
        })
    }

    return user
})

getRouterParam pulls :id from the URL. createError returns a proper HTTP error response — Nitro handles the rest.


Reading Query String Parameters

// server/api/users/index.get.ts

export default defineEventHandler(async (event) => {
    const query = getQuery(event)

    const page    = Number(query.page) || 1
    const perPage = Number(query.per_page) || 15
    const search  = String(query.search || '')

    // Use these to paginate your DB query
    return {
        data: [],
        page,
        per_page: perPage,
    }
})

getQuery parses the query string into an object. GET /api/users?page=2&search=john gives you { page: '2', search: 'john' }.


Handling POST Requests

// server/api/users/index.post.ts

import { z } from 'zod'

const createUserSchema = z.object({
    name:  z.string().min(2),
    email: z.string().email(),
})

export default defineEventHandler(async (event) => {
    const body = await readBody(event)

    const result = createUserSchema.safeParse(body)

    if (! result.success) {
        throw createError({
            statusCode: 422,
            statusMessage: 'Validation failed',
            data: result.error.flatten().fieldErrors,
        })
    }

    const { name, email } = result.data

    // Create the user in your database
    const user = await createUser({ name, email })

    setResponseStatus(event, 201)
    return user
})

readBody parses the request body as JSON. I'm using Zod for validation here — it's the standard for Nuxt 3 server routes. If you haven't used it, it's well worth the install:

npm install zod

Server Middleware

Files in server/middleware/ run on every request before it hits a handler. Useful for auth checks, logging, or setting headers.

// server/middleware/auth.ts

export default defineEventHandler(async (event) => {
    // Only protect /api/admin routes
    if (! event.path.startsWith('/api/admin')) {
        return
    }

    const token = getHeader(event, 'authorization')?.replace('Bearer ', '')

    if (! token) {
        throw createError({
            statusCode: 401,
            statusMessage: 'Unauthorised',
        })
    }

    // Verify the token and attach the user to the event context
    const user = await verifyToken(token)

    if (! user) {
        throw createError({
            statusCode: 401,
            statusMessage: 'Invalid token',
        })
    }

    event.context.user = user
})

The authenticated user is attached to event.context.user and available in any handler that runs after this middleware.


Using the Context in Handlers

// server/api/admin/dashboard.get.ts

export default defineEventHandler(async (event) => {
    const user = event.context.user

    return {
        message: `Welcome back, ${user.name}`,
        stats: await getDashboardStats(user.id),
    }
})

Clean and simple. No passing the user around manually.


Connecting to a Database with Drizzle

Nitro doesn't ship with a database layer, but Drizzle ORM is a great pairing. Install it and your driver of choice:

npm install drizzle-orm mysql2
npm install -D drizzle-kit

Create a database client as a Nitro plugin so it's initialised once:

// server/plugins/database.ts

import { drizzle } from 'drizzle-orm/mysql2'
import mysql from 'mysql2/promise'

declare module 'nitropack' {
    interface NitroApp {
        db: ReturnType<typeof drizzle>
    }
}

export default defineNitroPlugin(async (nitroApp) => {
    const connection = await mysql.createConnection({
        host:     process.env.DB_HOST,
        user:     process.env.DB_USERNAME,
        password: process.env.DB_PASSWORD,
        database: process.env.DB_DATABASE,
    })

    nitroApp.db = drizzle(connection)
})

Access it in a handler:

// server/api/posts/index.get.ts

import { posts } from '~/server/schema'

export default defineEventHandler(async (event) => {
    const db = useNitroApp().db

    const allPosts = await db.select().from(posts).limit(20)

    return allPosts
})

Calling Server Routes from Components

From the frontend, use useFetch or $fetch — both are built into Nuxt and handle the base URL automatically:

<script setup>
const { data: users, pending, error } = await useFetch('/api/users')
</script>

<template>
    <div v-if="pending">Loading...</div>
    <ul v-else>
        <li v-for="user in users.data" :key="user.id">
            {{ user.name }}
        </li>
    </ul>
</template>

On the server during SSR, Nuxt short-circuits the HTTP call and calls the handler directly — no actual network request. On the client it goes over HTTP as you'd expect.


Final Thoughts

Nuxt 3 server routes are genuinely one of its best features for full-stack work. You get a typed, file-based API with middleware support, built-in error handling, and seamless integration with your Vue components — all without spinning up a separate backend.

For smaller projects or internal tools, this alone might be all you need. For larger apps, you can still reach for Laravel or another API if you need the full power, but don't underestimate how far Nuxt's server layer can take you.