Skip to content

Instantly share code, notes, and snippets.

@JoshSalway
Last active March 11, 2026 03:10
Show Gist options
  • Select an option

  • Save JoshSalway/8a001ef7a82b05f05ebf4126ebba2d91 to your computer and use it in GitHub Desktop.

Select an option

Save JoshSalway/8a001ef7a82b05f05ebf4126ebba2d91 to your computer and use it in GitHub Desktop.
Claude Code Skills — Laravel, Deployment & Infrastructure

Laravel Cloud CLI — Full Command Reference

Auth

Command Description
cloud auth Browser-based OAuth login
cloud auth:token --add Manually add an API token (for headless environments)
cloud auth:token --list List stored tokens
cloud auth:token --remove Remove a stored token

Deploy

Command Description
cloud ship Guided first-time setup: create app, configure, and deploy
cloud deploy Deploy from current directory (uses .cloud/config.json)
cloud deploy --open Deploy then open in browser
cloud deploy:monitor Watch in-progress deployment

Environment Variables

Command Description
cloud environment:variables --action=set --key=KEY --value=VAL Set a single variable
cloud environment:variables Interactive variable management

Append --environment=<id> to target a specific environment when not auto-resolved.

Artisan / Commands

Command Description
cloud command:run "migrate --force" Run an Artisan command
cloud command:list List previously run commands
cloud command:get <id> Get output of a specific command

Logs

Command Description
cloud environment:logs Stream environment logs

Environments

Command Description
cloud environment:list List all environments
cloud environment:get Get environment details
cloud environment:create Create a new environment
cloud environment:update Update environment settings
cloud environment:delete Delete an environment

Applications

Command Description
cloud application:list List applications
cloud application:get Get application details
cloud application:create Create application
cloud application:update Update application
cloud application:delete Delete application

Deployments

Command Description
cloud deployment:list List deployments for environment
cloud deployment:get <id> Get deployment details

Database

Command Description
cloud database:open Open local tunnel to cloud database
cloud database:list List databases in a cluster
cloud database:create Create a database
cloud database:delete Delete a database
cloud database-cluster:list List database clusters
cloud database-cluster:create Create a database cluster
cloud database-snapshot:create Create a snapshot
cloud database-snapshot:list List snapshots
cloud database-restore:create Restore from snapshot

Cache

Command Description
cloud cache:list List caches
cloud cache:create Create a cache
cloud cache:update Update a cache
cloud cache:delete Delete a cache
cloud cache:types List available cache types

Object Storage (S3/R2)

Command Description
cloud bucket:list List buckets
cloud bucket:create Create a bucket
cloud bucket:update Update a bucket
cloud bucket:delete Delete a bucket
cloud bucket-key:list List bucket access keys
cloud bucket-key:create Create a bucket key

Domains

Command Description
cloud domain:list List domains
cloud domain:create Add a custom domain
cloud domain:verify Verify DNS is configured correctly
cloud domain:update Update domain settings
cloud domain:delete Remove a domain

Instances

Command Description
cloud instance:list List compute instances
cloud instance:sizes Show available instance sizes
cloud instance:create Create an instance
cloud instance:update Update an instance

Background Processes

Command Description
cloud background-process:list List background processes
cloud background-process:create Create a background process (queue worker, etc.)
cloud background-process:update Update a background process
cloud background-process:delete Delete a background process

WebSockets

Command Description
cloud websocket-cluster:list List WebSocket clusters
cloud websocket-application:list List WebSocket applications

Utilities

Command Description
cloud repo:config Link repo to Cloud app/env (writes .cloud/config.json)
cloud dashboard Open Cloud dashboard in browser
cloud browser Open deployed app in browser
cloud ip:addresses Get Cloud infrastructure IPs by region
cloud completions Install shell completions (bash/zsh/fish)
cloud self-update Update the CLI to latest version

Global Flags

Flag Description
--json Output as JSON (useful for scripting/CI)
--environment=<id> Target a specific environment
--no-interaction Non-interactive mode
name description
cloudflare-dns
Configure Cloudflare for DNS, CDN, SSL, and DDoS protection for Laravel apps. Use when setting up domains, adding DNS records, configuring SSL, or enabling Cloudflare features.

Cloudflare DNS & CDN

Cloudflare is the preferred DNS and CDN provider for Laravel apps — free tier covers all essentials.

Initial Setup

  1. Add your domain at dash.cloudflare.com
  2. Cloudflare provides two nameservers — update them at your registrar
  3. Wait for propagation (usually minutes, up to 24h)

DNS Records for a Laravel App

Type Name Content Proxy
A @ {server-ip} Proxied ✅
CNAME www {appname}.com Proxied ✅
CNAME staging {cloud-staging-url} DNS only ☁️

Proxied = traffic routes through Cloudflare (DDoS protection, CDN, SSL). DNS only = direct connection, no Cloudflare features (use for staging/internal).

SSL/TLS

Set SSL mode to Full (strict) in Cloudflare → SSL/TLS → Overview:

  • Flexible⚠️ don't use (insecure, HTTP between Cloudflare and origin)
  • Full — encrypts to origin with self-signed cert
  • Full (strict) — ✅ encrypts to origin with valid cert (recommended)

Enable Always Use HTTPS in SSL/TLS → Edge Certificates.

Pointing to Laravel Cloud

CNAME  @    {app}.laravel.cloud    DNS only
CNAME  www  {appname}.com          Proxied

Laravel Cloud handles its own SSL — use DNS only (grey cloud) for the root record pointing to Cloud.

Pointing to Forge / DigitalOcean

A  @    {server-ip}    Proxied

Forge provisions SSL via Let's Encrypt — set Cloudflare to Full (strict).

Pointing to Netlify

CNAME  @    {app}.netlify.app    DNS only

Or use Netlify's DNS proxy with Cloudflare as registrar only.

Page Rules / Redirect Rules

Redirect www → apex (or vice versa):

  • Match: www.{appname}.com/*
  • Action: Forwarding URL (301) → https://{appname}.com/$1

Useful Cloudflare Features (Free Tier)

Feature Where
DDoS protection Automatic
CDN caching Caching → Configuration
Analytics Analytics & Logs
Bot Fight Mode Security → Bots
Rate limiting (basic) Security → WAF
Email routing Email → Email Routing

Cache Purge After Deploy

If assets are cached, purge after deployment:

curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone-id}/purge_cache" \
  -H "Authorization: Bearer {api-token}" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

Or purge specific files:

--data '{"files":["https://{appname}.com/build/app.js"]}'
name description
git-workflow
Git workflow conventions for solo Laravel development. Covers branch naming, commit message format, PR workflow, and common git operations. Use when committing, branching, or managing git workflow.

Git Workflow

Commit Message Format

Use conventional commits with optional scopes:

type(scope): description

feat: add rebuy tracking to tournament page
feat(billing): add Stripe Checkout session
fix: show newsletters without thumbnails on home page
fix(routes): remove redirect that blocked SettingsController
test: expand test suite from 118 to 230 tests
test(e2e): add auth and dashboard tests
docs: add PRD and architecture docs
chore: apply Pint formatting to test files
chore: assign unique vite port

Types

Type When
feat New feature or capability
fix Bug fix
test Adding or expanding tests
docs Documentation only
chore Tooling, formatting, deps, config
refactor Code restructure without behaviour change

Scopes (optional)

Use the feature area: billing, admin, auth, e2e, seo, api, teams, mcp, ux, embed, routes, tests, ai, etc.

Branch Naming

feat/feature-name
fix/bug-description
test/test-coverage
docs/doc-name
chore/task-name
staging                    # staging environment branch
main                       # production branch

Feature branches are created from main and merged back via PR or direct merge.

Typical Workflow

Solo development (most projects)

# Work directly on main for small changes
git add -A && git commit -m "feat: add landing page pricing section"
git push

# Feature branch for larger work
git checkout -b feat/csv-import
# ... work ...
git add -A && git commit -m "feat(customers): CSV import — bulk add customers"
git push -u origin feat/csv-import
# Create PR if needed, or merge directly
git checkout main && git merge feat/csv-import && git push

With staging (production apps)

# Deploy to staging
git checkout staging
git merge main
git push  # triggers Laravel Cloud staging deploy

# Deploy to production
git checkout main
git push  # triggers Laravel Cloud production deploy

Common Operations

Amend last commit (before push)

git add -A && git commit --amend --no-edit

Squash merge a feature branch

git checkout main
git merge --squash feat/feature-name
git commit -m "feat: complete feature description"

View what changed

git diff --stat HEAD~3         # Last 3 commits summary
git log --oneline -10          # Recent history
git log --oneline --all --graph # Branch visualisation

Undo last commit (keep changes)

git reset --soft HEAD~1

Rules

  • Always push after committing (solo dev — no reason to keep local-only commits)
  • Keep commits atomic — one logical change per commit
  • Run php artisan pint before committing PHP changes
  • Run php artisan test before pushing to main
  • Never force-push to main or staging
  • Use descriptive commit messages — future-you reads these
name description
laravel-ai-sdk
Integrate AI features into Laravel apps using the official Laravel AI SDK (laravel/ai). Covers agents, tools, structured output, streaming, embeddings, image generation, audio, transcription, vector stores, and testing. Use when adding AI capabilities to a Laravel application.

Laravel AI SDK

The official first-party Laravel AI package. Supports Anthropic, OpenAI, Gemini, and many other providers.

IMPORTANT: Before implementing, fetch the latest docs:

WebFetch: https://laravel.com/docs/12.x/ai-sdk

The docs are the source of truth — the SDK is actively evolving.

Installation

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Add to .env:

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

Agents

Create an Agent

php artisan make:agent SalesCoach
php artisan make:agent SalesCoach --structured

Basic Agent

use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Enums\Lab;

#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-4-5-20250514')]
class ReviewResponder implements Agent
{
    use Promptable;

    public function instructions(): string
    {
        return 'You respond to customer reviews on behalf of a business. Be professional, warm, and concise.';
    }
}

// Usage
$response = (new ReviewResponder)->prompt("Write a reply to this 5-star review: \"{$review->content}\"");
echo $response;

Structured Output

use Laravel\Ai\Contracts\HasStructuredOutput;
use Illuminate\Contracts\JsonSchema\JsonSchema;

class SentimentAnalyzer implements Agent, HasStructuredOutput
{
    use Promptable;

    public function schema(JsonSchema $schema): array
    {
        return [
            'sentiment' => $schema->string()->required(),
            'score' => $schema->integer()->min(1)->max(10)->required(),
            'summary' => $schema->string()->required(),
        ];
    }
}

$result = (new SentimentAnalyzer)->prompt($text);
$result['sentiment']; // "positive"
$result['score'];     // 8

Conversations with Memory

use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Conversational;

class ChatBot implements Agent, Conversational
{
    use Promptable, RemembersConversations;
}

// Start conversation
$response = (new ChatBot)->forUser($user)->prompt('Hello!');
$conversationId = $response->conversationId;

// Continue
$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('Tell me more.');

Streaming

// In a route — returns SSE stream
Route::get('/chat', fn () => (new ChatBot)->stream('Hello!'));

// Vercel AI SDK protocol (for React frontends)
Route::get('/chat', fn () => (new ChatBot)->stream('Hello!')->usingVercelDataProtocol());

Queueing

(new ReviewResponder)
    ->queue("Generate a reply for review #{$review->id}")
    ->then(function ($response) use ($review) {
        $review->update(['ai_reply' => $response->text]);
    });

Attachments

use Laravel\Ai\Files\Document;
use Laravel\Ai\Files\Image;

$response = (new Analyzer)->prompt('Analyze this document', attachments: [
    Document::fromStorage('report.pdf'),
    Image::fromPath('/path/to/screenshot.png'),
    $request->file('upload'),
]);

Tools

php artisan make:tool WeatherLookup
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;

class WeatherLookup implements Tool
{
    public function description(): string
    {
        return 'Get current weather for a location';
    }

    public function handle(Request $request): string
    {
        return Http::get("https://api.weather.com/{$request['city']}")->body();
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'city' => $schema->string()->required(),
        ];
    }
}

// Use in agent
public function tools(): iterable
{
    return [new WeatherLookup];
}

Built-in Tools

use Laravel\Ai\Providers\Tools\WebSearch;
use Laravel\Ai\Providers\Tools\WebFetch;
use Laravel\Ai\Providers\Tools\FileSearch;

public function tools(): iterable
{
    return [
        new WebSearch,
        (new WebFetch)->allow(['docs.laravel.com']),
        new FileSearch(stores: ['store_id']),
    ];
}

Anonymous Agents

use function Laravel\Ai\{agent};

$response = agent(
    instructions: 'You are a copywriter.',
    tools: [new WebSearch],
)->prompt('Write a tagline for a poker timer app');

Images

use Laravel\Ai\Image;

$image = Image::of('A poker chip on green felt')
    ->landscape()
    ->quality('high')
    ->generate();

$path = $image->store(); // Saves to default disk

Embeddings & Vector Search

use Laravel\Ai\Embeddings;
use Illuminate\Support\Str;

// Generate embeddings
$embeddings = Str::of('Laravel is great')->toEmbeddings();

// Vector column in migration
Schema::ensureVectorExtensionExists();
$table->vector('embedding', dimensions: 1536);

// Query similar records
$results = Document::query()
    ->whereVectorSimilarTo('embedding', 'search query')
    ->limit(10)
    ->get();

Common SaaS AI Patterns

AI Review Replies

#[Provider(Lab::Anthropic)]
class ReviewReplier implements Agent
{
    use Promptable;

    public function __construct(private Business $business) {}

    public function instructions(): string
    {
        return "Reply on behalf of {$this->business->name}. Be professional and warm.";
    }
}

$reply = (new ReviewReplier($business))->prompt("Reply to: \"{$review->content}\" ({$review->rating} stars)");

AI Content Generation (DealerDesk pattern)

$description = agent(
    instructions: 'You are an automotive copywriter.',
)->prompt("Write a listing for: {$vehicle->year} {$vehicle->make} {$vehicle->model}");

Rate Limiting AI Endpoints

Route::post('/ai/generate', [AiController::class, 'generate'])
    ->middleware(['auth', 'throttle:10,1']);

Testing

use App\Ai\Agents\ReviewReplier;

test('generates a review reply', function () {
    ReviewReplier::fake(['Great review reply!']);

    $response = (new ReviewReplier($business))->prompt('Reply to this review');

    expect((string) $response)->toBe('Great review reply!');

    ReviewReplier::assertPrompted(fn ($prompt) => $prompt->contains('Reply'));
});

test('queues AI generation', function () {
    ReviewReplier::fake();

    (new ReviewReplier($business))->queue('Generate reply');

    ReviewReplier::assertQueued(fn ($prompt) => $prompt->contains('Generate'));
});

Configuration Attributes

#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-4-5-20250514')]
#[MaxSteps(10)]
#[MaxTokens(4096)]
#[Temperature(0.7)]
#[Timeout(120)]
#[UseCheapestModel]  // or #[UseSmartestModel]

Provider Support

Feature Providers
Text/Agents Anthropic, OpenAI, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama
Images OpenAI, Gemini, xAI
TTS OpenAI, ElevenLabs
STT OpenAI, ElevenLabs, Mistral
Embeddings OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI
name description
laravel-analytics
Add web analytics to Laravel applications. Covers Google Analytics 4 (GA4) and Fathom Analytics integration with Inertia/React apps. Use when setting up analytics, tracking events, or discussing analytics tools.

Web Analytics for Laravel

Current Setup

Currently using Fathom Analytics (usefathom.com) — privacy-focused, simple. Considering migrating to Google Analytics 4 for deeper insights.

Option 1: Google Analytics 4 (GA4)

Installation — Inertia/React

Add the GA4 script to your root Blade layout:

{{-- resources/views/app.blade.php --}}
<head>
    @production
    <script async src="https://www.googletagmanager.com/gtag/js?id={{ config('services.ga.measurement_id') }}"></script>
    <script>
        window.dataLayer = window.dataLayer || [];
        function gtag(){dataLayer.push(arguments);}
        gtag('js', new Date());
        gtag('config', '{{ config('services.ga.measurement_id') }}');
    </script>
    @endproduction
    @viteReactRefresh
    @vite(['resources/js/app.tsx'])
    @inertiaHead
</head>

Environment

GA_MEASUREMENT_ID=G-XXXXXXXXXX
// config/services.php
'ga' => [
    'measurement_id' => env('GA_MEASUREMENT_ID'),
],

Track Inertia Page Views

Inertia doesn't trigger full page loads, so you need to track navigation events:

// resources/js/app.tsx (or a layout component)
import { router } from '@inertiajs/react';

router.on('navigate', () => {
    if (typeof window.gtag === 'function') {
        window.gtag('event', 'page_view', {
            page_location: window.location.href,
            page_title: document.title,
        });
    }
});

Add the type declaration:

// resources/js/types/global.d.ts
declare function gtag(...args: unknown[]): void;

Custom Event Tracking

// Track a conversion event
function trackSignup() {
    window.gtag?.('event', 'sign_up', { method: 'WorkOS' });
}

function trackPurchase(plan: string, value: number) {
    window.gtag?.('event', 'purchase', {
        currency: 'AUD',
        value,
        items: [{ item_name: plan }],
    });
}

function trackFeatureUse(feature: string) {
    window.gtag?.('event', 'feature_used', { feature_name: feature });
}

Server-Side Events (GA4 Measurement Protocol)

For tracking events that happen on the server (e.g., webhook-triggered):

// app/Services/AnalyticsService.php
use Illuminate\Support\Facades\Http;

class AnalyticsService
{
    public function trackEvent(string $clientId, string $name, array $params = []): void
    {
        if (!config('services.ga.measurement_id')) return;

        Http::post('https://www.google-analytics.com/mp/collect', [
            'client_id' => $clientId,
            'events' => [
                ['name' => $name, 'params' => $params],
            ],
        ], [
            'measurement_id' => config('services.ga.measurement_id'),
            'api_secret' => config('services.ga.api_secret'),
        ]);
    }
}
GA_API_SECRET=...

Option 2: Fathom Analytics

Installation

Add the Fathom script to your root Blade layout:

{{-- resources/views/app.blade.php --}}
<head>
    @production
    <script src="https://cdn.usefathom.com/script.js" data-site="{{ config('services.fathom.site_id') }}" defer></script>
    @endproduction
</head>

Environment

FATHOM_SITE_ID=XXXXXXXX
// config/services.php
'fathom' => [
    'site_id' => env('FATHOM_SITE_ID'),
],

Track Inertia Page Views

Fathom auto-tracks SPAs if you use data-spa="auto":

<script src="https://cdn.usefathom.com/script.js" data-site="{{ config('services.fathom.site_id') }}" data-spa="auto" defer></script>

Custom Goal Tracking

// Track a goal/conversion
function trackGoal(goalId: string, cents: number = 0) {
    window.fathom?.trackGoal(goalId, cents);
}

// e.g., track signup
trackGoal('SIGNUP_GOAL_ID', 0);

// track purchase ($49 AUD)
trackGoal('PURCHASE_GOAL_ID', 4900);
// resources/js/types/global.d.ts
interface Window {
    fathom?: {
        trackPageview: () => void;
        trackGoal: (id: string, cents: number) => void;
    };
}

Comparison

Feature Fathom GA4
Privacy No cookies, GDPR-compliant by default Requires cookie consent banner
Setup complexity Minimal (1 script tag) Moderate (script + Inertia SPA tracking)
Cost $14/mo (100k pageviews) Free
Data depth Pageviews, goals, referrers Full funnel, audiences, conversions, ecommerce
Cookie consent Not required Required (AU/EU)
Server-side events API available Measurement Protocol
Real-time Yes Yes
Retention Unlimited 14 months (free)

When to use which

  • Fathom: Simple SaaS, privacy-first, don't need deep funnels
  • GA4: Need conversion funnels, audience segments, ecommerce tracking, or Google Ads integration

Cookie Consent (GA4 only)

If using GA4, you need a cookie consent banner for GDPR/privacy compliance:

// Only load GA after consent
function initGA() {
    const script = document.createElement('script');
    script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`;
    script.async = true;
    document.head.appendChild(script);

    window.dataLayer = window.dataLayer || [];
    window.gtag = function() { window.dataLayer.push(arguments); };
    window.gtag('js', new Date());
    window.gtag('config', GA_ID);
}

Or use GA4's built-in consent mode:

gtag('consent', 'default', {
    analytics_storage: 'denied',
});

// After user consents:
gtag('consent', 'update', {
    analytics_storage: 'granted',
});
name description
laravel-cashier-billing
Advanced Laravel Cashier patterns for Stripe billing including checkout sessions, customer portal, webhook handling, plan enforcement, and subscription lifecycle. Use when implementing billing, subscriptions, or payment features.

Laravel Cashier — Advanced Billing Patterns

Installation & Setup

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

Add Billable to User:

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

Environment

STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CASHIER_CURRENCY=aud
CASHIER_CURRENCY_LOCALE=en-AU

Plan Enum Pattern

// app/Enums/PlanEnum.php
enum PlanEnum: string
{
    case Free = 'free';
    case Pro = 'pro';
    case Business = 'business';

    public function stripePriceId(): ?string
    {
        return match ($this) {
            self::Free => null,
            self::Pro => config('services.stripe.prices.pro'),
            self::Business => config('services.stripe.prices.business'),
        };
    }

    public function limits(): array
    {
        return match ($this) {
            self::Free => ['items' => 5, 'sms_per_month' => 0, 'ai_generations' => 0],
            self::Pro => ['items' => 100, 'sms_per_month' => 500, 'ai_generations' => 50],
            self::Business => ['items' => -1, 'sms_per_month' => 5000, 'ai_generations' => -1],
        };
    }

    public function label(): string
    {
        return match ($this) {
            self::Free => 'Free',
            self::Pro => 'Pro',
            self::Business => 'Business',
        };
    }

    public static function fromPriceId(?string $priceId): self
    {
        if (!$priceId) return self::Free;
        foreach (self::cases() as $plan) {
            if ($plan->stripePriceId() === $priceId) return $plan;
        }
        return self::Free;
    }
}

User Model Helpers

public function plan(): PlanEnum
{
    if ($this->subscribed('default')) {
        $priceId = $this->subscription('default')->stripe_price;
        return PlanEnum::fromPriceId($priceId);
    }
    return PlanEnum::Free;
}

public function planLimit(string $key): int
{
    return $this->plan()->limits()[$key] ?? 0;
}

public function hasReachedLimit(string $key, int $currentUsage): bool
{
    $limit = $this->planLimit($key);
    return $limit !== -1 && $currentUsage >= $limit;
}

public function isPro(): bool
{
    return $this->plan() !== PlanEnum::Free;
}

Checkout Flow

// BillingController.php
public function checkout(Request $request)
{
    $request->validate(['price_id' => 'required|string']);

    return $request->user()
        ->newSubscription('default', $request->price_id)
        ->checkout([
            'success_url' => route('billing') . '?success=1',
            'cancel_url' => route('billing') . '?canceled=1',
        ]);
}

public function portal(Request $request)
{
    return $request->user()->redirectToBillingPortal(route('billing'));
}

Webhook Handling

Use Cashier's built-in webhook controller. Only extend if you need custom event handling:

// routes/web.php — Cashier auto-registers, but if needed:
Route::post('/stripe/webhook', [\Laravel\Cashier\Http\Controllers\WebhookController::class, 'handleWebhook']);

For custom handlers, extend the controller:

class StripeWebhookController extends \Laravel\Cashier\Http\Controllers\WebhookController
{
    public function handleCustomerSubscriptionDeleted(array $payload): Response
    {
        parent::handleCustomerSubscriptionDeleted($payload);
        // Custom cleanup logic
        return $this->successMethod();
    }

    public function handleInvoicePaymentFailed(array $payload): Response
    {
        // Notify user of failed payment
        $user = $this->getUserByStripeId($payload['data']['object']['customer']);
        if ($user) {
            $user->notify(new PaymentFailed());
        }
        return $this->successMethod();
    }
}

Plan Enforcement in Controllers

// In a controller or middleware
public function store(Request $request)
{
    $user = $request->user();
    $currentCount = $user->items()->count();

    if ($user->hasReachedLimit('items', $currentCount)) {
        return back()->with('error', 'You have reached your plan limit. Please upgrade to add more items.');
    }

    // ... create the item
}

Upgrade Prompt Component (React)

// resources/js/components/upgrade-prompt.tsx
interface UpgradePromptProps {
    feature: string;
    currentPlan: string;
}

export function UpgradePrompt({ feature, currentPlan }: UpgradePromptProps) {
    return (
        <div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
            <p className="text-sm text-amber-800">
                {feature} is available on Pro and above.
                You're currently on the {currentPlan} plan.
            </p>
            <Link href="/billing" className="mt-2 inline-block text-sm font-medium text-amber-900 underline">
                Upgrade now
            </Link>
        </div>
    );
}

Billing Page (Inertia)

Pass to the billing page:

return Inertia::render('Billing', [
    'plan' => $user->plan(),
    'subscribed' => $user->subscribed('default'),
    'onGracePeriod' => $user->subscription('default')?->onGracePeriod() ?? false,
    'portalUrl' => route('billing.portal'),
    'prices' => [
        'pro' => config('services.stripe.prices.pro'),
        'business' => config('services.stripe.prices.business'),
    ],
]);

Local Development

Test webhooks locally:

stripe listen --forward-to localhost:8000/stripe/webhook

Copy the webhook signing secret output to .env as STRIPE_WEBHOOK_SECRET.

Testing

test('billing page requires auth', function () {
    $this->get('/billing')->assertRedirect('/login');
});

test('billing page loads', function () {
    $user = User::factory()->create();
    $this->actingAs($user)->get('/billing')->assertOk();
});

test('free user cannot exceed item limit', function () {
    $user = User::factory()->create();
    // Create items up to the free limit
    Item::factory()->count(5)->for($user)->create();

    $this->actingAs($user)
        ->post('/items', ['name' => 'Over limit'])
        ->assertSessionHas('error');
});
name description
laravel-cloud-cli
Manage Laravel Cloud deployments, environments, databases, and infrastructure via the Cloud CLI. Use when deploying to Laravel Cloud, managing environments, viewing logs, or managing Cloud resources.

Laravel Cloud CLI

Installation

gh repo clone laravel/cloud-cli
cd cloud-cli
composer install

Add an alias to your shell config (~/.zshrc, ~/.bashrc, etc.):

echo 'alias cloud="php /path/to/cloud-cli/cloud"' >> ~/.zshrc
source ~/.zshrc

Requires: PHP 8.2+, Composer, GitHub CLI (gh), Git.

Authentication

cloud auth          # OAuth login via browser
cloud auth:token    # Manage API tokens (for CI)

Tokens stored in ~/.config/cloud/config.json.

Create a Long-Lived API Token

The default OAuth token expires in ~15 minutes and causes frequent 401 errors. Always create a named API token with a long expiration instead:

  1. Go to cloud.laravel.com → Profile → API Tokens
  2. Create a new token with expiration set to 1 year
  3. Copy the token and store it somewhere safe

Then set it as the active token:

# Edit ~/.config/cloud/config.json and set api_token to your new token
# Or run:
cloud auth:token

Test it works:

curl -s -H "Authorization: Bearer YOUR_TOKEN" https://cloud.laravel.com/api/meta/organization

CLI Auth Keeps Expiring (401)

When cloud environment:list returns 401:

  1. Check which tokens are stored: cat ~/.config/cloud/config.json
  2. Test each token manually with curl:
    curl -s -H "Authorization: Bearer TOKEN" https://cloud.laravel.com/api/meta/organization
  3. Update the api_token field in ~/.config/cloud/config.json to a working token
  4. If all tokens are expired, create a new long-lived token (see above)

Fallback: Use the REST API directly (see section below) — more reliable than the CLI when tokens are flaky.

Repository Setup

cd /path/to/{project}
cloud repo:config   # Link repo to Cloud app + environment

This creates a .cloud file in the project root with the app and environment IDs:

{
    "id": "app-xxx",
    "environment": "env-xxx"
}

These IDs are needed for direct API calls.

Deployment

cloud ship          # Guided first-time deployment
cloud deploy        # Deploy current branch
cloud deploy:monitor # Watch active deployment
cloud deployment:list # View deployment history

Typical Deploy Workflow

# Push to main and deploy
git push origin main
cloud deploy

# Monitor the deployment
cloud deploy:monitor

Environment Management

cloud environment:list
cloud environment:create
cloud environment:variables    # Load env vars from file
cloud environment:logs         # Tail logs

Load env vars from .env file

cloud environment:variables    # Interactive — loads from .env file

Auto-Injected Variables (DO NOT SET MANUALLY)

Laravel Cloud automatically injects env vars when resources are attached. Never set these manually — doing so overrides the injected values and can break deployments:

Resource Auto-injected vars
App APP_KEY, APP_URL
Database DB_CONNECTION, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD
Cache (Redis) REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_USERNAME (NOT CACHE_STORE/SESSION_DRIVER/QUEUE_CONNECTION — set those manually)
Bucket (S3) AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, AWS_URL, FILESYSTEM_DISK

Note: Redis resource injects connection credentials but does NOT set CACHE_STORE, SESSION_DRIVER, or QUEUE_CONNECTION. Set those manually:

CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

Only set custom app-specific vars like:

APP_NAME, APP_ENV, APP_DEBUG, VITE_*, STRIPE_*, API keys, feature flags

Required Packages for Resources

Resource Required package
Bucket (S3) composer require league/flysystem-aws-s3-v3
Cache (Redis) composer require predis/predis (or use phpredis extension)

Staging Cost Optimization

  • Use Flex compute with hibernate (5m) — sleeps when idle
  • Use Serverless Postgres with hibernate (300s)
  • Disable expensive background jobs/schedulers via env vars — trigger them on-demand (e.g. on page load) instead of on a schedule
  • Don't add Redis cache unless needed — database driver works fine for staging

Database Management

cloud database-cluster:list
cloud database-cluster:create
cloud database:list
cloud database:create
cloud database:open            # Connect locally (tunnel)
cloud database-snapshot:create # Backup
cloud database-restore:create  # Restore from snapshot

Other Resources

# Cache
cloud cache:create
cloud cache:list

# Object storage
cloud bucket:create
cloud bucket:list
cloud bucket-key:create

# Domains
cloud domain:create
cloud domain:verify

# WebSockets (Reverb)
cloud websocket-cluster:create
cloud websocket-application:create

# Background processes (queue workers)
cloud background-process:create
cloud background-process:list

# Run artisan commands
cloud command:run "php artisan migrate --force"

# View Cloud IP ranges (for Cloudflare whitelisting)
cloud ip:addresses

Quick Reference

Task Command
Deploy cloud deploy
View logs cloud environment:logs
Open dashboard cloud dashboard
Open app in browser cloud browser
Run migration cloud command:run "php artisan migrate --force"
Create DB snapshot cloud database-snapshot:create
Connect to DB locally cloud database:open
Set env vars cloud environment:variables
View deployments cloud deployment:list

JSON Output

All commands support --json for scripting:

cloud application:list --json | jq '.[0].name'
cloud environment:get --json | jq '.status'

REST API (Fallback when CLI auth fails)

When the CLI returns 401, use the REST API directly with curl/Python.

Get token + IDs

# Working token — test with:
curl -s -H "Authorization: Bearer TOKEN" https://cloud.laravel.com/api/meta/organization

# App + environment IDs are in .cloud file:
cat .cloud
# {"id":"app-xxx","environment":"env-xxx"}

# Or list all apps:
curl -s -H "Authorization: Bearer TOKEN" https://cloud.laravel.com/api/applications

Get current env vars

TOKEN="..."
ENV_ID="env-xxx"
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://cloud.laravel.com/api/environments/$ENV_ID" | \
  python3 -c "import sys,json; [print(f\"{v['key']}={v['value']}\") for v in json.load(sys.stdin)['data']['attributes']['environment_variables']]"

Set env vars

TOKEN="..."
ENV_ID="env-xxx"

curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "method": "set",
    "variables": [
      {"key": "APP_ENV", "value": "production"},
      {"key": "APP_DEBUG", "value": "false"}
    ]
  }' \
  "https://cloud.laravel.com/api/environments/$ENV_ID/variables"

Key details:

  • POST /api/environments/{id}/variables with method=set merges/updates (does not replace all)
  • PATCH /api/environments/{id} with environment_variables does NOT persist changes (returns 200 but ignores the update)
  • The response from POST always shows the full updated list

Trigger a deploy

TOKEN="..."
ENV_ID="env-xxx"
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" \
  "https://cloud.laravel.com/api/environments/$ENV_ID/deployments"

Check deployment status

TOKEN="..."
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://cloud.laravel.com/api/environments/$ENV_ID/deployments" | \
  python3 -c "import sys,json; [print(f\"{d['id']}: {d['attributes']['status']} — {d['attributes'].get('commit_message','')[:60]}\") for d in json.load(sys.stdin)['data'][:3]]"
name description
laravel-filament-admin
Add or configure Filament v3 admin panels in Laravel applications. Use when setting up admin panels, creating Filament resources, or managing superadmin access.

Filament v3 Admin Panel

Installation

composer require filament/filament
php artisan filament:install --panels

This creates app/Providers/Filament/AdminPanelProvider.php.

Superadmin Gate

Add is_superadmin to User model and migration:

// Migration
$table->boolean('is_superadmin')->default(false);

Implement FilamentUser on User model:

use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;

class User extends Authenticatable implements FilamentUser
{
    public function canAccessPanel(Panel $panel): bool
    {
        return $this->is_superadmin;
    }
}

Creating Resources

php artisan make:filament-resource ModelName --generate

This generates list, create, edit pages with form and table definitions.

Panel Configuration

In AdminPanelProvider.php:

->id('admin')
->path('admin')
->login()
->colors(['primary' => Color::Indigo])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->middleware([...])
->authMiddleware([Authenticate::class])

Publishing Assets

After installing or updating Filament:

php artisan filament:assets

If assets are missing in production, publish them:

php artisan vendor:publish --tag=filament-config
php artisan vendor:publish --tag=filament-views

Laravel 12 Notes

Laravel 12 uses bootstrap/app.php for middleware registration (no Kernel.php). Filament v3 handles its own middleware registration via the panel provider.

Testing Admin Panel

test('admin panel returns 403 for non-superadmin', function () {
    $user = User::factory()->create(['is_superadmin' => false]);
    $this->actingAs($user)->get('/admin')->assertForbidden();
});

test('admin panel accessible for superadmin', function () {
    $admin = User::factory()->create(['is_superadmin' => true, 'email_verified_at' => now()]);
    $this->actingAs($admin)->get('/admin')->assertOk();
});

test('admin panel redirects guests to login', function () {
    $this->get('/admin')->assertRedirect();
});
name description
laravel-forge
Deploy Laravel apps with Laravel Forge on DigitalOcean, AWS, or Linode. Use when setting up VPS hosting, configuring Nginx, managing SSL, queue workers, and deployment scripts. Fallback when Laravel Cloud doesn't suit the project.

Laravel Forge + DigitalOcean

Use Forge when you need full server control or Laravel Cloud doesn't suit the project.

Setup

  1. Create a server in Forge (DigitalOcean recommended)
  2. Connect your GitHub repo
  3. Create a site for your domain

Deployment Script

cd /home/forge/{appname}.com
git pull origin main
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart

SSL

Set up via Forge → Site → SSL → Let's Encrypt (free, auto-renews).

Queue Workers

In Forge → Server → Daemons:

php artisan queue:work --sleep=3 --tries=3 --max-time=3600

Scheduler

In Forge → Server → Scheduler:

* * * * * php /home/forge/{appname}.com/artisan schedule:run >> /dev/null 2>&1

Environment Variables

Edit directly in Forge → Site → Environment. Or SSH in and edit /home/forge/{appname}.com/.env.

SSH Access

ssh forge@{server-ip}

Useful Forge Features

  • Quick Deploy — auto-deploy on push to a branch
  • Deploy Webhooks — trigger deploy from CI/CD
  • Backups — scheduled database backups to S3
  • Monitoring — CPU/memory/disk alerts
  • Firewall — manage UFW rules from the dashboard
name description
laravel-herd
Local Laravel development with Laravel Herd. Use when setting up local dev environments, configuring .test domains, managing PHP versions, or running queue workers locally.

Laravel Herd — Local Development

Laravel Herd provides a zero-config local dev environment for macOS with PHP, Nginx, and database services built in.

Setup

cd /path/to/{project-name}
herd link {appname}

This creates https://{appname}.test with automatic SSL.

Per-Project Vite Ports

Each project needs a unique Vite dev server port to avoid conflicts when running multiple projects simultaneously:

// vite.config.ts
export default defineConfig({
    server: {
        port: 5173, // increment per project: 5174, 5175, etc.
    },
});

Starting Dev Servers

php artisan serve &
npm run dev &

Or use Herd's built-in Nginx (no artisan serve needed when using herd link).

Database

Herd provides MySQL and PostgreSQL. Configure .env:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE={appname}
DB_USERNAME=root
DB_PASSWORD=

Or for PostgreSQL:

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE={appname}
DB_USERNAME=postgres
DB_PASSWORD=

Queue & Scheduler

php artisan queue:work    # Process jobs
php artisan schedule:work # Run scheduler locally

PHP Version Management

herd php:list             # List installed PHP versions
herd php:use 8.3          # Switch global PHP version
herd isolate 8.2          # Use PHP 8.2 for current project only

Useful Commands

herd open                 # Open current site in browser
herd edit                 # Edit Nginx config for current site
herd logs                 # Tail Nginx logs
herd restart              # Restart all services
herd status               # Show running services

SSL Certificates

Herd automatically provisions SSL for all .test domains. If a cert is missing:

herd secure {appname}     # Manually secure a site
herd unsecure {appname}   # Remove SSL
name description
laravel-inertia-react
Build Laravel Inertia.js pages with React and TypeScript. Use when creating new pages, components, layouts, or working with Inertia props, forms, and routing in a Laravel React app.

Laravel + Inertia.js + React + TypeScript

Page Structure

Pages live in resources/js/pages/. Each page is a React component that receives props from the Laravel controller.

Controller → Page

// app/Http/Controllers/DashboardController.php
public function index()
{
    return Inertia::render('dashboard', [
        'stats' => $this->getStats(),
        'recentItems' => $user->items()->latest()->limit(5)->get(),
    ]);
}
// resources/js/pages/dashboard.tsx
import AppLayout from '@/layouts/app-layout';

interface DashboardProps {
    stats: { total: number; active: number };
    recentItems: Array<{ id: number; name: string }>;
}

export default function Dashboard({ stats, recentItems }: DashboardProps) {
    return (
        <AppLayout>
            <div className="p-6">
                {/* page content */}
            </div>
        </AppLayout>
    );
}

Routing

Routes are defined in routes/web.php and referenced in React via Ziggy:

import { Link } from '@inertiajs/react';
import { route } from 'ziggy-js';

<Link href={route('dashboard')}>Dashboard</Link>

If Ziggy is not installed, use string paths directly.

Forms

Use Inertia's useForm hook:

import { useForm } from '@inertiajs/react';

const { data, setData, post, processing, errors } = useForm({
    name: '',
    email: '',
});

const submit = (e: React.FormEvent) => {
    e.preventDefault();
    post(route('users.store'));
};

Shared Data

Access shared data (auth user, flash messages) via usePage:

import { usePage } from '@inertiajs/react';

const { auth, flash } = usePage().props;

Layouts

Use persistent layouts to preserve state across navigation:

// resources/js/layouts/app-layout.tsx
import { AppSidebar } from '@/components/app-sidebar';

export default function AppLayout({ children }: { children: React.ReactNode }) {
    return (
        <div className="flex min-h-screen">
            <AppSidebar />
            <main className="flex-1">{children}</main>
        </div>
    );
}

UI Components

Use Radix UI primitives with Tailwind styling (shadcn/ui pattern):

import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { Plus } from 'lucide-react';

<Button variant="outline" size="sm">
    <Plus className="mr-2 h-4 w-4" />
    Add Item
</Button>

Icons come from lucide-react, not Heroicons.

TypeScript Types

Define shared types in resources/js/types/index.d.ts:

export interface User {
    id: number;
    name: string;
    email: string;
    is_superadmin: boolean;
}

export interface PageProps {
    auth: { user: User };
    flash: { success?: string; error?: string };
}

Vite Config

// vite.config.ts
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
    plugins: [
        laravel({ input: 'resources/js/app.tsx', ssr: 'resources/js/ssr.tsx', refresh: true }),
        react({ babel: { plugins: [['babel-plugin-react-compiler']] } }),
        tailwindcss(),
    ],
    server: { port: 5173 }, // increment per project
});

Testing Inertia Pages

test('page returns correct component', function () {
    $user = User::factory()->create();
    $this->actingAs($user)
        ->get('/dashboard')
        ->assertOk()
        ->assertInertia(fn ($page) => $page
            ->component('dashboard')
            ->has('stats')
            ->has('recentItems')
        );
});
name description
laravel-infrastructure
Infrastructure overview for Laravel apps — which platform to use for local dev, staging, production, DNS, and static sites. Use when deciding where to deploy or how environments are structured.

Infrastructure & Deployment Overview

Environment Stack

Environment Platform Domain Pattern Purpose
Local Laravel Herd {appname}.test Development
Staging Laravel Cloud {appname}-staging.laravel.cloud Pre-production testing
Production Laravel Cloud {appname}.com Live

Platform Decision Tree

New Laravel SaaS project?
├── Yes → Laravel Cloud (staging + production)
│         └── DNS via Cloudflare
│         └── Local dev via Herd
├── Static site?
│   └── Netlify
├── Frontend-only React/Next.js app?
│   └── Vercel (or Netlify)
├── Need full server control?
│   └── Laravel Forge + DigitalOcean
│       └── DNS via Cloudflare

Platform Skills

  • Local devlaravel-herd
  • Staging/Production hostinglaravel-cloud-cli
  • Forge + DigitalOceanlaravel-forge
  • DNS / CDNcloudflare-dns
  • Static sitesnetlify
  • Frontend-onlyvercel
name description
laravel-livewire
Build interactive Laravel pages with Livewire and Alpine.js. Use when the project uses Livewire instead of Inertia, or when adding Livewire components.

Laravel Livewire + Alpine.js

Some projects use Livewire + Alpine.js instead of Inertia + React. This is the Blade-based interactive stack.

When to Use Livewire vs Inertia

Livewire + Alpine Inertia + React
Best for Content sites, admin panels, forms Complex SPA-like apps
Frontend Blade + Alpine.js React + TypeScript
Reactivity Server-driven (wire:model) Client-side (useState)
SEO Better by default (server-rendered) Needs SSR setup
JS bundle Minimal Larger (React)

Default: Inertia + React for new SaaS projects. Livewire for content-heavy sites or when Blade is preferred.

Installation

composer require livewire/livewire

For Flux UI components:

composer require livewire/flux

Creating Components

php artisan make:livewire SearchBar
php artisan make:livewire Newsletter/Subscribe

This creates:

  • app/Livewire/SearchBar.php (component class)
  • resources/views/livewire/search-bar.blade.php (template)

Component Example

// app/Livewire/SearchBar.php
use Livewire\Component;

class SearchBar extends Component
{
    public string $query = '';

    public function search()
    {
        // Triggers re-render with updated results
    }

    public function render()
    {
        return view('livewire.search-bar', [
            'results' => Brand::where('name', 'like', "%{$this->query}%")
                ->limit(10)
                ->get(),
        ]);
    }
}
{{-- resources/views/livewire/search-bar.blade.php --}}
<div>
    <input type="text" wire:model.live.debounce.300ms="query" placeholder="Search...">

    @if($results->count())
        <ul>
            @foreach($results as $result)
                <li>{{ $result->name }}</li>
            @endforeach
        </ul>
    @endif
</div>

Usage in Blade

<livewire:search-bar />
{{-- or --}}
@livewire('search-bar')

Alpine.js Integration

Alpine handles client-side interactivity without server round-trips:

{{-- Dropdown --}}
<div x-data="{ open: false }">
    <button @click="open = !open">Menu</button>
    <div x-show="open" @click.outside="open = false" x-transition>
        {{-- dropdown content --}}
    </div>
</div>

{{-- Mobile navigation toggle --}}
<div x-data="{ mobileOpen: false }">
    <button @click="mobileOpen = !mobileOpen" class="md:hidden">
        <span x-show="!mobileOpen">Menu</span>
        <span x-show="mobileOpen">Close</span>
    </button>
    <nav :class="mobileOpen ? 'block' : 'hidden md:block'">
        {{-- nav links --}}
    </nav>
</div>

Tailwind + Blade Layout

{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<head>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @livewireStyles
</head>
<body>
    <x-navigation />
    <main>
        {{ $slot }}
    </main>
    <x-footer />
    @livewireScripts
</body>
</html>

Flux UI Components

Flux provides pre-built Livewire components:

<flux:button>Click me</flux:button>
<flux:input wire:model="name" label="Name" />
<flux:modal name="confirm-delete">
    <p>Are you sure?</p>
    <flux:button wire:click="delete">Delete</flux:button>
</flux:modal>

Testing Livewire

use Livewire\Livewire;

test('search returns results', function () {
    Brand::factory()->create(['name' => 'Test Brand']);

    Livewire::test(SearchBar::class)
        ->set('query', 'Test')
        ->assertSee('Test Brand');
});

test('search shows empty state', function () {
    Livewire::test(SearchBar::class)
        ->set('query', 'nonexistent')
        ->assertDontSee('Test Brand');
});
name description
laravel-mcp-server
Create a TypeScript MCP server for a Laravel application. Use when adding MCP tools, creating an MCP server, or exposing app data to AI assistants.

MCP Server for Laravel Apps

Create a TypeScript MCP server that exposes the Laravel app's data and actions as tools for AI assistants.

Directory Structure

mcp/
├── package.json
├── tsconfig.json
├── README.md
└── src/
    └── index.ts

Setup

mkdir -p mcp/src
cd mcp

package.json

{
  "name": "{appname}-mcp",
  "version": "1.0.0",
  "description": "MCP server for {AppName}",
  "type": "module",
  "bin": {
    "{appname}-mcp": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.7.0"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Server Implementation

// mcp/src/index.ts
#!/usr/bin/env node

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  Tool,
} from '@modelcontextprotocol/sdk/types.js';

const BASE_URL = process.env.{APPNAME}_BASE_URL ?? 'http://localhost:8000';
const API_TOKEN = process.env.{APPNAME}_API_TOKEN ?? '';

// HTTP helper
async function apiRequest(method: string, path: string, body?: unknown): Promise<unknown> {
  const url = `${BASE_URL}${path}`;
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    Accept: 'application/json',
    'X-Requested-With': 'XMLHttpRequest',
  };
  if (API_TOKEN) {
    headers['Authorization'] = `Bearer ${API_TOKEN}`;
  }

  const response = await fetch(url, {
    method,
    headers,
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });

  const text = await response.text();
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} ${response.statusText}: ${text}`);
  }
  try {
    return JSON.parse(text);
  } catch {
    return text;
  }
}

// Define tools based on the app's API endpoints
const tools: Tool[] = [
  {
    name: 'list_{resources}',
    description: 'List all {resources}',
    inputSchema: {
      type: 'object',
      properties: {},
    },
  },
  // Add more tools matching your API endpoints
];

// Server setup
const server = new Server(
  { name: '{appname}-mcp', version: '1.0.0' },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  try {
    switch (name) {
      case 'list_{resources}': {
        const data = await apiRequest('GET', '/api/{resources}');
        return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
      }
      default:
        throw new Error(`Unknown tool: ${name}`);
    }
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
  }
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch(console.error);

Laravel API Setup (if not already present)

The MCP server calls the Laravel API via HTTP. Ensure the app has:

  1. Sanctum installed for API token auth:

    composer require laravel/sanctum
    php artisan install:api
  2. API routes in routes/api.php:

    Route::middleware('auth:sanctum')->group(function () {
        Route::apiResource('{resources}', {Resource}Controller::class);
    });
  3. HasApiTokens on User model:

    use Laravel\Sanctum\HasApiTokens;
    class User extends Authenticatable
    {
        use HasApiTokens;
    }

Build and Configure

cd mcp && npm install && npm run build

Claude Code config (.mcp.json in project root)

{
  "mcpServers": {
    "{appname}": {
      "command": "node",
      "args": ["mcp/dist/index.js"],
      "env": {
        "{APPNAME}_BASE_URL": "http://localhost:8000",
        "{APPNAME}_API_TOKEN": "{sanctum-token}"
      }
    }
  }
}

Tool Design Guidelines

  • One tool per API endpoint (list, get, create, update, delete)
  • Use descriptive tool names: list_tournaments, get_tournament, create_tournament
  • Include pagination params where applicable
  • Return formatted JSON for readability
  • Include error context in error responses
name description
laravel-nightwatch
Set up Laravel Nightwatch for error tracking and application monitoring. Use when adding monitoring, error tracking, or performance observability to a Laravel application.

Laravel Nightwatch

Nightwatch is Laravel's first-party application monitoring and error tracking service.

Installation

composer require laravel/nightwatch
php artisan nightwatch:install

Configuration

Add to .env:

NIGHTWATCH_TOKEN=...
NIGHTWATCH_DEPLOYMENT=production

The install command publishes config/nightwatch.php and sets up the service provider.

What It Tracks

  • Exceptions — automatic error capture with stack traces
  • Slow queries — database queries exceeding threshold
  • HTTP requests — response times, status codes
  • Queue jobs — job execution, failures, duration
  • Cache operations — hits, misses, writes
  • Mail — sent emails, failures
  • Notifications — dispatched notifications
  • Scheduled tasks — execution times, failures

Environment-Specific Setup

// config/nightwatch.php
return [
    'token' => env('NIGHTWATCH_TOKEN'),
    'deployment' => env('NIGHTWATCH_DEPLOYMENT', 'production'),
    'enabled' => env('NIGHTWATCH_ENABLED', true),
];

Only enable in staging and production:

# .env (local)
NIGHTWATCH_ENABLED=false

# .env (staging)
NIGHTWATCH_ENABLED=true
NIGHTWATCH_DEPLOYMENT=staging

# .env (production)
NIGHTWATCH_ENABLED=true
NIGHTWATCH_DEPLOYMENT=production

Testing

Nightwatch doesn't require special test configuration — it's automatically disabled in the testing environment.

Dashboard

View errors, performance, and monitoring data at nightwatch.laravel.com.

Nightwatch MCP Server

Nightwatch has an official MCP server that lets AI assistants list apps, browse issues, view stack traces, update issue status, and add comments.

Setup for Claude Code

claude mcp add --transport http nightwatch https://nightwatch.laravel.com/mcp

Then run /mcp in a Claude Code session to complete OAuth authentication via browser.

With Laravel Boost: Run php artisan boost:install for automatic MCP configuration.

What It Can Do

  • List applications — see all monitored apps
  • Browse issues — view recent exceptions and errors
  • View stack traces — full stack traces with code context
  • Update issue status — mark issues as resolved/ignored
  • Add comments — document findings on issues

Usage Examples

  • "Show me the most recent exceptions in my app"
  • "What's the stack trace for issue #42?"
  • "Mark issue #42 as resolved and add a comment explaining the fix"
  • "Summarize the top 5 unresolved issues for triage"

Security

  • OAuth authentication — authorizes via browser on first connection
  • Access restricted to your organizations
  • All actions are logged in issue activity history with user + AI agent attribution

MCP Server URL

For manual configuration in other tools:

https://nightwatch.laravel.com/mcp
name description
laravel-pest-testing
Write comprehensive Pest PHP tests for Laravel applications. Use when adding tests, expanding test coverage, or when the user mentions testing, test suite, or Pest.

Laravel Pest Testing

Conventions

  • Use Pest PHP syntax (not PHPUnit classes)
  • Tests go in tests/Feature/ (most tests) and tests/Unit/
  • Use RefreshDatabase trait for feature tests
  • Use factories for all test data
  • Test file naming: {Feature}Test.php (e.g., BillingTest.php, AdminPanelTest.php)

Pest.php Setup

// tests/Pest.php
uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature');

Test Patterns

Auth-gated route

test('page requires authentication', function () {
    $this->get('/dashboard')->assertRedirect('/login');
});

test('page loads for authenticated user', function () {
    $user = User::factory()->create();
    $this->actingAs($user)->get('/dashboard')->assertOk();
});

Inertia page assertions

test('dashboard returns correct inertia component', function () {
    $user = User::factory()->create();
    $this->actingAs($user)
        ->get('/dashboard')
        ->assertOk()
        ->assertInertia(fn ($page) => $page
            ->component('dashboard')
            ->has('stats')
        );
});

Filament admin panel

test('admin panel returns 403 for non-superadmin', function () {
    $user = User::factory()->create(['is_superadmin' => false]);
    $this->actingAs($user)->get('/admin')->assertForbidden();
});

test('admin panel accessible for superadmin', function () {
    $admin = User::factory()->create(['is_superadmin' => true, 'email_verified_at' => now()]);
    $this->actingAs($admin)->get('/admin')->assertOk();
});

CRUD operations

test('user can create resource', function () {
    $user = User::factory()->create();
    $this->actingAs($user)
        ->post('/resources', ['name' => 'Test', 'description' => 'A test resource'])
        ->assertRedirect();

    $this->assertDatabaseHas('resources', ['name' => 'Test']);
});

test('user cannot access another users resource', function () {
    $owner = User::factory()->create();
    $other = User::factory()->create();
    $resource = Resource::factory()->for($owner)->create();

    $this->actingAs($other)->get("/resources/{$resource->id}")->assertForbidden();
});

Billing / Cashier

test('billing page requires auth', function () {
    $this->get('/billing')->assertRedirect('/login');
});

test('billing page loads for authenticated user', function () {
    $user = User::factory()->create();
    $this->actingAs($user)->get('/billing')->assertOk();
});

Public pages (Landing, Terms, Privacy)

test('landing page loads', function () {
    $this->get('/')->assertOk();
});

test('terms page loads', function () {
    $this->get('/terms')->assertOk();
});

test('privacy page loads', function () {
    $this->get('/privacy')->assertOk();
});

API endpoints

test('api returns json', function () {
    $user = User::factory()->create();
    $this->actingAs($user)
        ->getJson('/api/resources')
        ->assertOk()
        ->assertJsonStructure(['data' => [['id', 'name']]]);
});

Jobs and queues

use Illuminate\Support\Facades\Queue;

test('action dispatches job', function () {
    Queue::fake();
    $user = User::factory()->create();

    $this->actingAs($user)->post('/trigger-action');

    Queue::assertPushed(ProcessAction::class);
});

Mail

use Illuminate\Support\Facades\Mail;

test('action sends notification email', function () {
    Mail::fake();
    // ... trigger action
    Mail::assertSent(NotificationMail::class);
});

Running Tests

php artisan test                    # Run all tests
php artisan test --filter=BillingTest  # Run specific test file
php artisan test --parallel         # Run in parallel

Coverage Strategy

Prioritise tests for:

  1. Auth gates (every route should test auth/guest access)
  2. CRUD happy paths
  3. Authorization (users can't access other users' data)
  4. Billing/subscription gates
  5. Admin panel access control
  6. Public pages load without error
  7. API endpoints return correct structure
  8. Jobs are dispatched correctly
  9. Emails are sent correctly
  10. Edge cases and error states
name description
laravel-playwright-e2e
Set up and write Playwright end-to-end tests for Laravel applications. Use when adding E2E tests, browser testing, or when the user mentions Playwright.

Playwright E2E Testing for Laravel

Installation

npm init playwright@latest

Select TypeScript, tests in e2e/, and install browsers.

Configuration

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
    testDir: './e2e',
    fullyParallel: false,
    forbidOnly: !!process.env.CI,
    retries: process.env.CI ? 2 : 0,
    workers: 1,
    reporter: 'list',
    use: {
        baseURL: 'http://appname.test', // Herd .test domain
        trace: 'on-first-retry',
    },
    projects: [
        { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    ],
    globalSetup: './e2e/global-setup.ts',
});

Note: Most projects run under Laravel Herd with .test domains (e.g., https://reviewmate.test). Use the Herd domain as baseURL.

Global Setup — Fresh Database per Run

// e2e/global-setup.ts
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const appRoot = path.resolve(__dirname, '..');

export default async function globalSetup() {
    console.log('\n[E2E] Running php artisan migrate:fresh --seed --seeder=E2eSeeder ...');
    execSync('php artisan migrate:fresh --seed --seeder=E2eSeeder', {
        cwd: appRoot,
        stdio: 'inherit',
    });
    console.log('[E2E] Database seeded successfully.\n');
}

E2E Seeder

// database/seeders/E2eSeeder.php
class E2eSeeder extends Seeder
{
    public function run(): void
    {
        $user = User::factory()->create([
            'email' => 'e2e@appname.test',
            'is_superadmin' => false,
        ]);

        $admin = User::factory()->create([
            'email' => 'admin@appname.test',
            'is_superadmin' => true,
        ]);

        // Seed realistic test data: the main model, related records, etc.
    }
}

Auth — WorkOS Bypass Route

All apps use WorkOS AuthKit (no local login form). Create a bypass route gated to local/testing only:

// routes/web.php
if (app()->environment('local', 'testing') && config('app.e2e', false)) {
    Route::get('/_e2e/login', function (Request $request) {
        $user = User::where('email', $request->query('email'))->firstOrFail();
        Auth::login($user);
        return redirect('/dashboard');
    });
}

Add APP_E2E=true to .env for local development.

Auth Helper

// e2e/helpers/auth.ts
import { Page } from '@playwright/test';

const E2E_EMAIL = 'e2e@appname.test';

export async function loginAsE2eUser(page: Page, email = E2E_EMAIL): Promise<void> {
    await page.goto(`/_e2e/login?email=${encodeURIComponent(email)}`);
    await page.waitForURL('**/dashboard', { timeout: 10_000 });
}

Test File Structure

e2e/
├── global-setup.ts
├── helpers/
│   └── auth.ts
├── auth.spec.ts
├── dashboard.spec.ts
├── misc-pages.spec.ts        # smoke tests for all settings/misc pages
├── {feature}.spec.ts          # one file per major feature

Test Patterns

Auth flow

test.describe('Auth', () => {
    test('landing page loads without auth redirect', async ({ page }) => {
        await page.goto('/');
        await expect(page).not.toHaveURL(/login/);
        await expect(page.locator('body')).toBeVisible();
    });

    test('dashboard redirects unauthenticated users', async ({ page }) => {
        await page.goto('/dashboard');
        await expect(page).not.toHaveURL(/dashboard/);
    });

    test('e2e login lands on dashboard', async ({ page }) => {
        await loginAsE2eUser(page);
        await expect(page).toHaveURL(/dashboard/);
    });
});

Page smoke test (bulk pattern)

For every auth-gated page, verify it loads without errors:

test.describe('Settings pages', () => {
    test.beforeEach(async ({ page }) => {
        await loginAsE2eUser(page);
    });

    for (const path of ['/settings/billing', '/settings/notifications', '/settings/integrations']) {
        test(`${path} loads without error`, async ({ page }) => {
            await page.goto(path);
            await expect(page.locator('body')).not.toContainText('Whoops');
            await expect(page.locator('body')).not.toContainText('500');
        });
    }
});

CRUD flow

test('can create and view resource', async ({ page }) => {
    await loginAsE2eUser(page);
    await page.goto('/resources/create');
    await page.fill('input[name="name"]', 'Test Resource');
    await page.click('button[type="submit"]');
    await expect(page.locator('text=Test Resource')).toBeVisible();
});

Form validation

test('shows validation errors for empty required fields', async ({ page }) => {
    await loginAsE2eUser(page);
    await page.goto('/resources/create');
    await page.click('button[type="submit"]');
    await expect(page.locator('text=required')).toBeVisible();
});

Dashboard with data

test('dashboard shows stats and navigation', async ({ page }) => {
    await loginAsE2eUser(page);
    await expect(page.locator('body')).not.toContainText('Whoops');
    // Verify seeded data appears
    await expect(page.locator('body')).toContainText('E2E Test');
    // Verify nav links present
    await expect(page.locator('body')).toContainText('Dashboard');
});

Running Tests

npx playwright test                    # Run all
npx playwright test auth.spec.ts      # Run specific file
npx playwright test --ui              # Interactive mode
npx playwright show-report            # View HTML report

Common Issues

Inertia 409 version mismatch

Run npm run build to regenerate assets before running tests.

WorkOS redirect on /_e2e/login

Ensure APP_E2E=true is set in .env and the bypass route is registered.

Flaky timeouts

Use workers: 1 and fullyParallel: false for Inertia apps — parallel test runs can cause database conflicts with RefreshDatabase.

Related

For interactive visual auditing (not automated tests), see the laravel-ui-audit skill which uses Playwright MCP browser tools to manually inspect pages.

name description
laravel-project-docs
Generate project documentation including PRD, competitive analysis, OpenAPI spec, internal docs, and deployment guides. Use when the user asks for docs, PRD, competitor analysis, architecture docs, or wants to understand project status.

Laravel Project Documentation

Documentation Structure

Every project should have a docs/ directory with this structure:

docs/
├── PRD.md                    # Product Requirements Document
├── NEXT_STEPS.md             # Prioritised task list / roadmap
├── COMPETITIVE_ANALYSIS.md   # Competitor research (optional, recommended)
├── openapi.yaml              # OpenAPI 3.1 spec for API endpoints
├── internal/                 # Internal-only docs
│   ├── ARCHITECTURE.md       # System architecture and key decisions
│   ├── DEPLOYMENT.md         # Deployment guide (Forge, Herd, Cloud)
│   └── {FEATURE}_SETUP.md   # Setup guides for integrations (Twilio, Stripe, etc.)
└── public/                   # Docs visible to end users (optional)
    └── API.md                # Public API documentation

PRD Template

# {AppName} — Product Requirements Document

> Version: 1.0 — {date}
> Status: {MVP / Feature-complete / Production}

---

## Overview

{One paragraph: what it is, who it's for, key differentiator.}

---

## Target Users

| Persona | Profile | Pain point solved |
|---|---|---|
| {name} | {description} | {what problem this solves} |

---

## Feature Requirements

### {Category}

- [x] {completed feature}
- [ ] {planned feature}

---

## Tech Stack

- Backend: Laravel 12, PHP 8.2+
- Frontend: Inertia.js + React 19 + TypeScript + Tailwind CSS v4
- Auth: WorkOS AuthKit
- Billing: Laravel Cashier (Stripe)
- Admin: Filament v3
- Testing: Pest PHP + Playwright
- Deployment: {Forge / Herd / Laravel Cloud}

Competitive Analysis Template

Research competitors using WebSearch. Structure the analysis as:

# {AppName} — Competitive Analysis

**Date:** {date}

## Competitors Researched

| Competitor | Positioning | Pricing (USD/mo) |
|---|---|---|
| {name} | {one-line positioning} | {price range} |

## Feature Comparison Table

| Feature | Competitor A | Competitor B | {AppName} |
|---|---|---|---|
| {feature} | Y | N | Y |

## 1. Features Competitors Have That {AppName} is MISSING

### HIGH PRIORITY
#### 1.1 {Feature Name}
- **Who has it:** {competitors}
- **What it does:** {description}
- **Why it matters:** {business justification}
- **Gap level:** Critical / High / Medium / Low

## 2. Features {AppName} Has That Competitors DON'T

## 3. Pricing Analysis

## 4. Recommended Roadmap Based on Gaps

NEXT_STEPS Template

# {AppName} — Next Steps

> Last updated: {date}

## Priority 1 — Ship Blockers
- [ ] {task}

## Priority 2 — Launch Features
- [ ] {task}

## Priority 3 — Nice to Have
- [ ] {task}

## Completed
- [x] {task} *({date})*

OpenAPI Spec

Generate OpenAPI 3.1 specs by reading the app's routes and controllers:

php artisan route:list --columns=method,uri,name,action --json
openapi: 3.1.0
info:
  title: {AppName} API
  version: 1.0.0
  description: |
    REST API for {AppName}.
    All authenticated endpoints require a valid Bearer token (Laravel Sanctum).

servers:
  - url: http://localhost:8000
    description: Local development
  - url: https://{production-url}
    description: Production

tags:
  - name: {resource}
    description: {resource} management

paths:
  /api/{resource}:
    get:
      summary: List {resources}
      tags: [{resource}]
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/{Resource}'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
  schemas:
    {Resource}:
      type: object
      properties:
        id: { type: integer }

Internal Architecture Doc

# {AppName} — Architecture

## System Overview
{Diagram description or high-level flow}

## Key Models
| Model | Purpose | Key Relationships |
|---|---|---|
| User | {purpose} | hasMany {related} |

## Authentication Flow
WorkOS AuthKit → callback → create/update local User → session

## Billing Flow
Stripe Checkout → webhook → Cashier subscription → PlanEnum limits

## Queue / Jobs
| Job | Trigger | Purpose |
|---|---|---|
| {JobName} | {when triggered} | {what it does} |

## Key Decisions
- {decision}: {rationale}

When to Generate Docs

  • New project: Generate PRD + NEXT_STEPS after initial scaffold
  • Before launch: Generate COMPETITIVE_ANALYSIS + DEPLOYMENT
  • When API exists: Generate OpenAPI spec from route:list + controllers
  • When asked "how far along": Update NEXT_STEPS with completion percentages
  • When asked about competition: Generate or update COMPETITIVE_ANALYSIS using WebSearch
name description
laravel-queues-jobs
Laravel queues, jobs, scheduling, and failed job handling. Use when creating jobs, setting up schedulers, configuring queue workers, or debugging failed jobs.

Laravel Queues, Jobs & Scheduling

Creating Jobs

php artisan make:job ProcessPayment
// app/Jobs/ProcessPayment.php
class ProcessPayment implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $backoff = 60; // seconds between retries
    public int $timeout = 120;

    public function __construct(
        private Order $order,
    ) {}

    public function handle(): void
    {
        // Process the payment
    }

    public function failed(Throwable $exception): void
    {
        // Notify admin, log error, etc.
        Log::error("Payment failed for order {$this->order->id}", [
            'error' => $exception->getMessage(),
        ]);
    }
}

Dispatching

ProcessPayment::dispatch($order);
ProcessPayment::dispatch($order)->onQueue('payments');
ProcessPayment::dispatch($order)->delay(now()->addMinutes(5));

Unique Jobs

class FetchExternalData implements ShouldQueue, ShouldBeUnique
{
    public int $uniqueFor = 3600; // 1 hour

    public function uniqueId(): string
    {
        return $this->user->id;
    }
}

Scheduling

Laravel 12 uses routes/console.php:

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('reminders:send')->everyMinute();
Schedule::command('reports:generate')->dailyAt('06:00')->timezone('Australia/Brisbane');
Schedule::command('cache:prune-stale-tags')->hourly();
Schedule::job(new FetchExternalData)->everyFifteenMinutes();
Schedule::command('queue:prune-failed --hours=48')->daily();

Run Locally

php artisan schedule:work  # Runs scheduler in foreground

Common Schedule Patterns

->everyMinute()
->everyFiveMinutes()
->everyFifteenMinutes()
->hourly()
->dailyAt('06:00')
->weeklyOn(1, '8:00')  // Monday at 8am
->monthly()
->timezone('Australia/Brisbane')
->withoutOverlapping()
->onOneServer()          // For multi-server setups
->runInBackground()

Queue Configuration

Local Development

QUEUE_CONNECTION=database
php artisan queue:work           # Process jobs
php artisan queue:work --tries=3 # With retry limit
php artisan queue:listen         # Restart on code change (slower)

Production (Laravel Cloud)

Configure queue workers in the Laravel Cloud dashboard. Cloud handles scaling and restarts.

Production (Forge)

Add a daemon in Forge:

  • Command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
  • Directory: /home/forge/appname.com
  • User: forge

Failed Jobs

php artisan queue:failed         # List failed jobs
php artisan queue:retry all      # Retry all failed
php artisan queue:retry {id}     # Retry specific job
php artisan queue:forget {id}    # Delete failed job
php artisan queue:flush          # Delete all failed jobs
php artisan queue:prune-failed --hours=48  # Clean old failures

Failed Jobs Table

php artisan make:queue-failed-table
php artisan migrate

Job Chaining

Bus::chain([
    new ProcessPayment($order),
    new SendReceipt($order),
    new UpdateAnalytics($order),
])->dispatch();

Job Batching

use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$batch = Bus::batch([
    new ImportCsvRow($row1),
    new ImportCsvRow($row2),
    new ImportCsvRow($row3),
])->then(function (Batch $batch) {
    // All jobs completed
})->catch(function (Batch $batch, Throwable $e) {
    // First failure
})->finally(function (Batch $batch) {
    // Batch finished (success or failure)
})->dispatch();

Testing

use Illuminate\Support\Facades\Queue;

test('action dispatches job', function () {
    Queue::fake();

    $this->actingAs($user)->post('/process-payment', ['order_id' => $order->id]);

    Queue::assertPushed(ProcessPayment::class, fn ($job) =>
        $job->order->id === $order->id
    );
});

test('action dispatches job on correct queue', function () {
    Queue::fake();

    ProcessPayment::dispatch($order);

    Queue::assertPushedOn('payments', ProcessPayment::class);
});

test('no jobs dispatched for invalid input', function () {
    Queue::fake();

    $this->actingAs($user)->post('/process-payment', ['order_id' => 999]);

    Queue::assertNothingPushed();
});
name description
laravel-saas-scaffold
Scaffold a new Laravel SaaS application with Inertia.js, React, TypeScript, Tailwind CSS, shadcn/ui, Pest, Filament admin, WorkOS auth, and Stripe billing. Use when creating a new Laravel project or SaaS app from scratch.

Laravel SaaS Scaffold

Stack

  • Backend: Laravel 12 with PHP 8.2+
  • Frontend: Inertia.js + React + TypeScript + Tailwind CSS v4 + Vite
  • UI Components: Radix UI primitives + shadcn/ui pattern (via @radix-ui/react-*) + Lucide React icons
  • Testing: Pest PHP (feature + unit) + Playwright (E2E)
  • Admin: Filament v3 with superadmin gate
  • Auth: WorkOS AuthKit via workos/workos-php-laravel
  • Billing: Laravel Cashier (Stripe)
  • Code Style: Laravel Pint

Step 1: Create Laravel Project

laravel new project-name
cd project-name

Select Inertia + React + TypeScript + Pest when prompted. Laravel 12 uses the laravel new interactive installer by default.

Step 2: Install Core Dependencies

PHP

composer require inertiajs/inertia-laravel workos/workos-php-laravel laravel/cashier filament/filament tightenco/ziggy
composer require --dev pestphp/pest pestphp/pest-plugin-laravel larastan/larastan

Node

Detect package manager: check for pnpm-lock.yaml → pnpm, yarn.lock → yarn, bun.lockb → bun, else → npm.

npm install @inertiajs/react @headlessui/react lucide-react tailwind-merge
npm install @radix-ui/react-avatar @radix-ui/react-checkbox @radix-ui/react-collapsible @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-label @radix-ui/react-navigation-menu @radix-ui/react-select @radix-ui/react-separator @radix-ui/react-slot @radix-ui/react-toggle @radix-ui/react-toggle-group @radix-ui/react-tooltip
npm install -D @tailwindcss/vite @vitejs/plugin-react @types/react @types/react-dom babel-plugin-react-compiler eslint-plugin-react eslint-plugin-react-hooks prettier-plugin-tailwindcss

Step 3: Directory Structure

app/
├── Http/Controllers/
│   ├── AuthController.php
│   ├── DashboardController.php
│   └── BillingController.php
├── Models/
│   └── User.php (add is_superadmin, HasApiTokens)
├── Enums/
│   └── PlanEnum.php
└── Policies/
config/
├── workos.php (vendor:publish)
├── cashier.php
resources/js/
├── app.tsx
├── ssr.tsx
├── components/
│   ├── ui/ (shadcn-style primitives)
│   └── app-sidebar.tsx
├── layouts/
│   ├── app-layout.tsx
│   └── auth-layout.tsx
├── pages/
│   ├── dashboard.tsx
│   ├── Landing.tsx
│   ├── Billing.tsx
│   ├── Terms.tsx
│   ├── Privacy.tsx
│   └── settings/
│       └── profile.tsx
└── types/
    └── index.d.ts
routes/
├── web.php
└── console.php
tests/
├── Feature/
│   ├── Auth/
│   ├── BillingTest.php
│   ├── AdminPanelTest.php
│   └── LegalPagesTest.php
└── Unit/

Step 4: Auth Setup (WorkOS)

Publish config: php artisan vendor:publish --provider="WorkOS\Laravel\WorkOSServiceProvider"

Add to .env:

WORKOS_API_KEY=sk_...
WORKOS_CLIENT_ID=client_...
WORKOS_REDIRECT_URI=http://localhost:8000/auth/callback

Create AuthController with login/callback/logout using SDK methods (never construct OAuth URLs manually).

Step 5: Billing Setup (Cashier)

php artisan cashier:install
php artisan migrate

Create PlanEnum with tiers (e.g., Free, Pro, Business). Add plan check helpers to User model.

Add to .env:

STRIPE_KEY=pk_...
STRIPE_SECRET=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...

Step 6: Filament Admin Panel

php artisan filament:install --panels

Gate superadmin access in AppPanelProvider:

->authGuard('web')
->login()
->middleware([...])

Add is_superadmin boolean column to users migration. Gate panel access:

public function canAccessPanel(Panel $panel): bool
{
    return $this->is_superadmin;
}

Step 7: Legal Pages

Always create Terms of Service and Privacy Policy pages as Inertia pages:

  • resources/js/pages/Terms.tsx
  • resources/js/pages/Privacy.tsx
  • Add routes and footer links

Step 8: Herd/Valet Dev Setup

Assign a unique Vite port per project to run multiple projects simultaneously:

// vite.config.ts
server: { port: 5174 } // increment per project

Step 9: Testing Foundation

// tests/Pest.php
uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature');

Write Pest tests for: auth flows, admin panel access, billing pages, legal pages, all CRUD.

Step 10: SEO Basics

  • Add robots.txt route
  • Add dynamic sitemap.xml route
  • Add Open Graph meta tags to public pages

Verification

php artisan route:list
php artisan test
npm run build
php artisan pint --test
name description
laravel-seo
Add SEO features to Laravel applications including sitemap, robots.txt, Open Graph meta tags, and structured data. Use when the user mentions SEO, sitemap, robots, meta tags, or social sharing.

Laravel SEO

Robots.txt Route

// routes/web.php
Route::get('/robots.txt', function () {
    $content = "User-agent: *\nAllow: /\n";
    $content .= "Sitemap: " . url('/sitemap.xml');

    if (app()->environment('staging')) {
        $content = "User-agent: *\nDisallow: /\n";
    }

    return response($content, 200, ['Content-Type' => 'text/plain']);
});

Dynamic Sitemap

// routes/web.php
Route::get('/sitemap.xml', function () {
    $urls = collect();

    // Static pages
    $urls->push(['loc' => url('/'), 'priority' => '1.0', 'changefreq' => 'daily']);
    $urls->push(['loc' => url('/pricing'), 'priority' => '0.8']);
    $urls->push(['loc' => url('/terms'), 'priority' => '0.3']);
    $urls->push(['loc' => url('/privacy'), 'priority' => '0.3']);

    // Dynamic pages (public resources)
    $items = Item::where('is_public', true)->get();
    foreach ($items as $item) {
        $urls->push([
            'loc' => url("/items/{$item->slug}"),
            'lastmod' => $item->updated_at->toIso8601String(),
            'priority' => '0.7',
        ]);
    }

    return response()->view('sitemap', ['urls' => $urls], 200, [
        'Content-Type' => 'application/xml',
    ]);
});
{{-- resources/views/sitemap.blade.php --}}
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@foreach($urls as $url)
    <url>
        <loc>{{ $url['loc'] }}</loc>
        @if(isset($url['lastmod']))<lastmod>{{ $url['lastmod'] }}</lastmod>@endif
        @if(isset($url['changefreq']))<changefreq>{{ $url['changefreq'] }}</changefreq>@endif
        @if(isset($url['priority']))<priority>{{ $url['priority'] }}</priority>@endif
    </url>
@endforeach
</urlset>

Open Graph Meta Tags

Add to the <head> section of your app layout. For Inertia apps, use the Head component:

// resources/js/components/seo-head.tsx
import { Head } from '@inertiajs/react';

interface SeoProps {
    title: string;
    description?: string;
    image?: string;
    url?: string;
    type?: string;
}

export function SeoHead({ title, description, image, url, type = 'website' }: SeoProps) {
    const siteName = 'AppName';
    const fullTitle = `${title} | ${siteName}`;

    return (
        <Head>
            <title>{fullTitle}</title>
            <meta name="description" content={description} />
            <meta property="og:title" content={fullTitle} />
            <meta property="og:description" content={description} />
            <meta property="og:type" content={type} />
            {url && <meta property="og:url" content={url} />}
            {image && <meta property="og:image" content={image} />}
            <meta property="og:site_name" content={siteName} />
            <meta name="twitter:card" content="summary_large_image" />
            <meta name="twitter:title" content={fullTitle} />
            <meta name="twitter:description" content={description} />
            {image && <meta name="twitter:image" content={image} />}
        </Head>
    );
}

Usage in pages:

export default function Landing() {
    return (
        <>
            <SeoHead
                title="Home"
                description="Your app tagline here"
                image="/og-image.png"
            />
            {/* page content */}
        </>
    );
}

Server-Side OG Tags for Public Pages

For pages that need to be crawled (public profiles, shared links), set meta tags from the controller:

return Inertia::render('PublicProfile', [
    'profile' => $profile,
    'meta' => [
        'title' => $profile->name,
        'description' => $profile->bio,
        'image' => $profile->avatar_url,
        'url' => url("/profiles/{$profile->slug}"),
    ],
]);

Canonical URLs

<Head>
    <link rel="canonical" href={url} />
</Head>

Structured Data (JSON-LD)

For richer search results:

<Head>
    <script type="application/ld+json">
        {JSON.stringify({
            '@context': 'https://schema.org',
            '@type': 'SoftwareApplication',
            name: 'AppName',
            description: 'App description',
            applicationCategory: 'BusinessApplication',
            offers: {
                '@type': 'Offer',
                price: '0',
                priceCurrency: 'AUD',
            },
        })}
    </script>
</Head>
name description
laravel-sms
Integrate SMS into Laravel applications using Twilio or ClickSend. Use when adding SMS notifications, appointment reminders, two-way messaging, or discussing SMS providers. ClickSend is the Australian alternative.

SMS Integration for Laravel

Provider Choice

Provider Best for Pricing
Twilio Global, feature-rich, two-way SMS Pay per message (~$0.05 AUD/msg AU)
ClickSend Australian-based, simpler, good AU rates Pay per message (~$0.04 AUD/msg AU)

Preference: ClickSend for Australian-focused apps (local support, competitive AU rates). Twilio for apps needing advanced features (programmable voice, WhatsApp, global reach).


Option 1: Twilio

Installation

composer require twilio/sdk

Environment

TWILIO_SID=AC...
TWILIO_AUTH_TOKEN=...
TWILIO_PHONE_NUMBER=+61...

SMS Service

// app/Services/SmsService.php
use Twilio\Rest\Client;

class SmsService
{
    private Client $client;

    public function __construct()
    {
        $this->client = new Client(
            config('services.twilio.sid'),
            config('services.twilio.auth_token'),
        );
    }

    public function send(string $to, string $message): string
    {
        $result = $this->client->messages->create($to, [
            'from' => config('services.twilio.phone_number'),
            'body' => $message,
            'statusCallback' => route('twilio.status'),
        ]);

        return $result->sid;
    }
}

Config

// config/services.php
'twilio' => [
    'sid' => env('TWILIO_SID'),
    'auth_token' => env('TWILIO_AUTH_TOKEN'),
    'phone_number' => env('TWILIO_PHONE_NUMBER'),
],

Webhook Routes

// routes/web.php
Route::post('/twilio/webhook', [TwilioController::class, 'incoming'])
    ->name('twilio.incoming');
Route::post('/twilio/status', [TwilioController::class, 'status'])
    ->name('twilio.status');

Webhook Signature Verification

use Twilio\Security\RequestValidator;

class TwilioController extends Controller
{
    public function incoming(Request $request)
    {
        if (!app()->environment('local') && !$this->isValidTwilioRequest($request)) {
            abort(403);
        }

        // Handle incoming SMS
        $from = $request->input('From');
        $body = $request->input('Body');

        // Process message...

        return response('<Response></Response>', 200, ['Content-Type' => 'text/xml']);
    }

    public function status(Request $request)
    {
        // Track delivery status: queued, sent, delivered, failed, undelivered
        $sid = $request->input('MessageSid');
        $status = $request->input('MessageStatus');

        SmsLog::where('twilio_sid', $sid)->update(['status' => $status]);

        return response('OK');
    }

    private function isValidTwilioRequest(Request $request): bool
    {
        $validator = new RequestValidator(config('services.twilio.auth_token'));
        return $validator->validate(
            $request->header('X-Twilio-Signature', ''),
            $request->fullUrl(),
            $request->all(),
        );
    }
}

Queued SMS Sending

// app/Jobs/SendSms.php
class SendSms implements ShouldQueue
{
    public function __construct(
        private string $to,
        private string $message,
    ) {}

    public function handle(SmsService $sms): void
    {
        $sms->send($this->to, $this->message);
    }
}

SMS Templates with Merge Fields

public function renderTemplate(string $template, array $data): string
{
    return preg_replace_callback('/\{\{(\w+)\}\}/', function ($matches) use ($data) {
        return $data[$matches[1]] ?? $matches[0];
    }, $template);
}

// Usage
$message = $this->renderTemplate(
    'Hi {{name}}, your appointment is tomorrow at {{time}}.',
    ['name' => $patient->name, 'time' => $appointment->time->format('g:ia')],
);

Appointment Reminders Pattern (ClinicPingV2)

// app/Console/Kernel.php or routes/console.php
Schedule::command('reminders:send')->everyMinute();

// app/Console/Commands/SendReminders.php
// Query appointments 24h and 2h out, send SMS if not already reminded
$appointments = Appointment::query()
    ->whereBetween('starts_at', [now()->addHours(23), now()->addHours(25)])
    ->whereNull('reminded_at_24h')
    ->get();

foreach ($appointments as $appointment) {
    SendSms::dispatch($appointment->patient->phone, $reminder);
    $appointment->update(['reminded_at_24h' => now()]);
}

Rate Limiting

Route::post('/send-sms', [SmsController::class, 'send'])
    ->middleware(['auth', 'throttle:20,1']); // 20 per minute

Testing

test('sms is dispatched for appointment reminder', function () {
    Queue::fake();

    $appointment = Appointment::factory()->create([
        'starts_at' => now()->addHours(24),
    ]);

    Artisan::call('reminders:send');

    Queue::assertPushed(SendSms::class);
});

Option 2: ClickSend (Australian Alternative)

Installation

composer require clicksend/clicksend-php

Environment

CLICKSEND_USERNAME=...
CLICKSEND_API_KEY=...
CLICKSEND_FROM=+61...

Config

// config/services.php
'clicksend' => [
    'username' => env('CLICKSEND_USERNAME'),
    'api_key' => env('CLICKSEND_API_KEY'),
    'from' => env('CLICKSEND_FROM'),
],

SMS Service

// app/Services/SmsService.php
use ClickSend\Api\SMSApi;
use ClickSend\Configuration;
use ClickSend\Model\SmsMessage;
use ClickSend\Model\SmsMessageCollection;
use GuzzleHttp\Client;

class SmsService
{
    private SMSApi $api;

    public function __construct()
    {
        $config = Configuration::getDefaultConfiguration()
            ->setUsername(config('services.clicksend.username'))
            ->setPassword(config('services.clicksend.api_key'));

        $this->api = new SMSApi(new Client(), $config);
    }

    public function send(string $to, string $message): string
    {
        $sms = new SmsMessage([
            'from' => config('services.clicksend.from'),
            'to' => $to,
            'body' => $message,
        ]);

        $collection = new SmsMessageCollection(['messages' => [$sms]]);
        $response = $this->api->smsSendPost($collection);

        return $response->getData()->getMessages()[0]->getMessageId();
    }
}

ClickSend vs Twilio

ClickSend also supports: email, fax, postcard, MMS, and voice. The API is REST-based and simpler than Twilio's. For basic SMS sending in Australian apps, ClickSend is often the better choice.

Using an Interface for Swappable Providers

// app/Contracts/SmsProvider.php
interface SmsProvider
{
    public function send(string $to, string $message): string;
}

// Bind in AppServiceProvider
$this->app->bind(SmsProvider::class, function () {
    return match (config('services.sms.driver')) {
        'clicksend' => new ClickSendSmsService(),
        'twilio' => new TwilioSmsService(),
        default => new NullSmsService(), // for testing
    };
});
SMS_DRIVER=clicksend
name description
laravel-ui-audit
Use Playwright MCP browser tools to visually audit and verify UI/UX across a Laravel application. Use when checking frontend design, finding visual bugs, verifying responsive layout, or confirming that pages render correctly after changes.

Laravel UI/UX Audit via Playwright Browser

Use the Playwright MCP browser tools to navigate the running app, take screenshots, inspect the DOM, and verify that every page renders correctly. This catches bugs that unit/feature tests miss: broken layouts, missing elements, console errors, failed asset loading, dark mode issues, and mobile responsiveness problems.

Prerequisites

  • The Laravel app must be running locally (e.g., via Herd at https://appname.test or php artisan serve at http://localhost:8000)
  • Determine the base URL before starting

Step 1: Authenticate

Most apps use WorkOS AuthKit with no local login form. Use the dev login bypass:

Navigate to: {baseURL}/_e2e/login?email=e2e@appname.test
   — or —
Navigate to: {baseURL}/dev/login

If neither bypass exists, check routes/web.php for a local/testing login route. If none exists, suggest creating one gated to app()->environment('local', 'testing').

Verify: confirm the browser lands on /dashboard after login.

Step 2: Discover All Routes

Run via bash to get the full route list:

php artisan route:list --columns=method,uri,name | grep -E "^  GET"

Group routes into categories:

  • Public pages: landing, terms, privacy, pricing, public profiles
  • Auth-gated pages: dashboard, settings, CRUD pages
  • Admin pages: /admin (Filament)

Step 3: Systematic Page Audit

Visit every page and check for issues. For each page:

3a. Take a screenshot and inspect

  1. browser_navigate to the page URL
  2. browser_snapshot to get the accessibility tree / DOM state
  3. browser_take_screenshot for visual verification
  4. browser_console_messages to check for JS errors

3b. Check for common issues

Check What to look for
500 / Whoops Laravel error page rendered instead of the actual page
Blank page Inertia hydration failed — check console for React errors
Missing nav Sidebar or header not rendering (layout not wrapping page)
Broken images img elements with failed onerror or missing src
Console errors JS exceptions, failed network requests, React key warnings
Flash messages Success/error toasts not appearing after form submissions
Dark mode Hardcoded colors instead of semantic tokens (gray-800 vs bg-card)
Overflow/scroll Horizontal scrollbar on mobile, content overflowing containers
Empty states Pages with no data should show a helpful empty state, not a blank area
Loading states Skeleton/spinner shown while data loads, not a layout shift

3c. Mobile responsiveness

Resize the browser to mobile width and re-check key pages:

browser_resize: { width: 375, height: 812 }  // iPhone viewport

Check:

  • Navigation collapses to hamburger menu
  • Tables become scrollable or stack vertically
  • Forms are usable on small screens
  • Text doesn't overflow containers
  • Touch targets are at least 44x44px

Then reset:

browser_resize: { width: 1280, height: 720 }  // Desktop viewport

Step 4: Interactive Flow Testing

Test critical user flows end-to-end in the browser:

CRUD flows

  1. Navigate to the create page
  2. Fill in the form using browser_fill_form or browser_click + browser_type
  3. Submit and verify redirect + success message
  4. Verify the new item appears in the list
  5. Edit the item — verify form pre-fills correctly
  6. Delete the item — verify confirmation dialog and removal

Navigation flows

  1. Click through all sidebar/nav links
  2. Verify each link navigates to the correct page
  3. Check breadcrumbs update correctly
  4. Verify back button works (Inertia preserves scroll position)

Form validation

  1. Submit forms with empty required fields
  2. Verify validation errors appear inline (not just a flash message)
  3. Submit with invalid data (bad email, too-short text)
  4. Verify the form preserves input after validation failure

Step 5: Filament Admin Audit

If the app has a Filament admin panel:

  1. Log in as superadmin (create one if needed via User::factory()->create(['is_superadmin' => true]))
  2. Navigate to /admin
  3. Check each resource list page loads
  4. Verify create/edit forms work
  5. Check that non-superadmin users get 403

Step 6: Report Findings

Compile a summary of issues found:

## UI/UX Audit Results — {AppName}

### Critical (broken functionality)
- [ ] {page}: {description of issue}

### Visual (layout/design issues)
- [ ] {page}: {description of issue}

### Console Errors
- [ ] {page}: {error message}

### Mobile Issues
- [ ] {page}: {description of issue}

### Recommendations
- {suggestion for improvement}

Common Fixes

Inertia 409 version mismatch

The page returns 409 instead of 200. Run npm run build to regenerate assets, or clear the Inertia version cache.

Dark mode broken on specific page

Look for hardcoded Tailwind classes like text-gray-900 or bg-white. Replace with semantic tokens: text-foreground, bg-background, bg-card.

Missing sidebar on page

The page component isn't wrapped in AppLayout. Add the layout wrapper.

Empty page after navigation

Check the Inertia render() call in the controller — the component name must match the file path in resources/js/pages/ exactly (case-sensitive).

Console error: "Failed to fetch"

API call failing — check that the route exists in routes/web.php or routes/api.php and the controller method returns JSON for XHR requests.

name description
laravel-wayfinder
Use Laravel Wayfinder for type-safe TypeScript route and controller action generation. Use when adding routes, creating controllers, or connecting React frontend to Laravel backend routes.

Laravel Wayfinder

Wayfinder generates fully-typed TypeScript functions from your Laravel controllers and routes — no more hardcoded URLs.

Installation

composer require laravel/wayfinder
npm i -D @laravel/vite-plugin-wayfinder

Vite Config

// vite.config.ts
import { wayfinder } from '@laravel/vite-plugin-wayfinder';

export default defineConfig({
    plugins: [
        wayfinder(),
        // ... other plugins
    ],
});

Generating Types

php artisan wayfinder:generate
php artisan wayfinder:generate --path=resources/js/wayfinder
php artisan wayfinder:generate --with-form

Generated files go in resources/js/wayfinder/. These can be gitignored — they regenerate on every build. The Vite plugin auto-regenerates during development.

Usage in React Components

Importing Actions

// Import specific controller actions
import { show, update } from '@/actions/App/Http/Controllers/PostController';

// URL string only
show.url(1); // "/posts/1"

// Full route object
show(1); // { url: "/posts/1", method: "get" }

With Inertia Links

import { Link } from '@inertiajs/react';
import { show } from '@/actions/App/Http/Controllers/PostController';

<Link href={show.url(1)}>View Post</Link>

With Inertia useForm

import { useForm } from '@inertiajs/react';
import { store } from '@/actions/App/Http/Controllers/PostController';

const form = useForm({ name: '' });
form.submit(store()); // POSTs to /posts

With Query Parameters

import { index } from '@/actions/App/Http/Controllers/PostController';

index({ query: { page: 2, sort: 'name' } });
// { url: "/posts?page=2&sort=name", method: "get" }

// Merge with current query params
index({ mergeQuery: { page: 3 } });

Parameter Formats

// Single parameter
show(1);
show({ id: 1 });

// Custom route binding
// Route: /posts/{post:slug}
show('my-post-slug');
show({ slug: 'my-post-slug' });

// Multiple parameters
update([1, 2]);
update({ post: 1, author: 2 });

Forms (with --with-form)

<form {...store.form()}>
    {/* action="/posts" method="post" */}
</form>

<form {...update.form(1)}>
    {/* action="/posts/1?_method=PATCH" method="post" */}
</form>

When to Regenerate

Run php artisan wayfinder:generate after:

  • Adding new controllers or routes
  • Changing route parameters
  • Adding or removing controller methods

The Vite plugin handles this automatically during npm run dev.

Notes

  • Prefer importing specific actions over entire controllers (enables tree-shaking)
  • Reserved JS words like delete become deleteMethod
  • Wayfinder replaces the need for Ziggy's route() helper in most cases
name description
netlify
Deploy static sites and frontend-only projects to Netlify. Use when deploying marketing sites, documentation, or static HTML/JS projects. Not suitable for full Laravel apps.

Netlify — Static Site Deployment

Netlify is ideal for static sites, marketing pages, and documentation. Not suitable for full Laravel apps (use Laravel Cloud or Forge instead).

Connect a Repo

  1. Go to app.netlify.com → Add new site → Import from Git
  2. Select GitHub repo and branch (usually main)
  3. Configure build settings:
Setting Value
Build command npm run build (or leave empty for static)
Publish directory dist, out, public, or _site

Custom Domain

  1. Site settings → Domain management → Add custom domain
  2. Add DNS records in Cloudflare:
CNAME  @    {app}.netlify.app    DNS only ☁️

Or use Netlify DNS (transfer nameservers to Netlify).

Environment Variables

Site settings → Environment variables → Add variable.

Redirects

Add a _redirects file in your publish directory:

/old-path    /new-path    301
/*           /index.html  200    # SPA fallback

Or use netlify.toml:

[[redirects]]
  from = "/old-path"
  to = "/new-path"
  status = 301

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

Deploy Previews

Netlify automatically creates preview URLs for every pull request. Useful for reviewing changes before merging.

Netlify CLI

npm install -g netlify-cli
netlify login
netlify deploy          # Deploy to preview URL
netlify deploy --prod   # Deploy to production
netlify env:list        # List env vars
netlify open            # Open site in browser

When to Use Netlify vs Vercel

Netlify Vercel
Best for Static sites, simple SPAs Next.js, React apps
Edge functions Yes Yes
Forms Built-in No
Identity/Auth Built-in No
Next.js Works Optimised

Claude Code Skills

A collection of Claude Code skills for Laravel development, deployment, and infrastructure.

How to Install

Download individual skill files and place them in ~/.claude/skills/{skill-name}/skill.md.

Or clone all at once:

gh gist clone 8a001ef7a82b05f05ebf4126ebba2d91 ~/.claude/skills-gist
# Then copy individual skills you want

Available Skills

Laravel Development

Skill Description
laravel-saas-scaffold Scaffold a new Laravel SaaS app with Inertia, React, Tailwind, Filament, WorkOS, Stripe
laravel-inertia-react Build Inertia.js pages with React and TypeScript
laravel-livewire Build interactive pages with Livewire and Alpine.js
laravel-filament-admin Set up Filament v3 admin panels
laravel-cashier-billing Stripe billing with Laravel Cashier
laravel-pest-testing Write Pest PHP tests
laravel-playwright-e2e Playwright end-to-end tests
laravel-queues-jobs Queues, jobs, scheduling, and failed job handling
laravel-ai-sdk Integrate AI with the official Laravel AI SDK
laravel-mcp-server Create a TypeScript MCP server for a Laravel app
laravel-wayfinder Type-safe TypeScript routes with Laravel Wayfinder
laravel-seo Sitemap, robots.txt, Open Graph, structured data
laravel-analytics Google Analytics 4 and Fathom integration
laravel-twilio-sms SMS with Twilio or ClickSend
laravel-nightwatch Error tracking with Laravel Nightwatch
laravel-project-docs Generate PRD, OpenAPI spec, and project docs
laravel-ui-audit Visual UI/UX audit via Playwright browser

Deployment & Infrastructure

Skill Description
laravel-infrastructure Overview — which platform to use and when
laravel-cloud-cli Deploy and manage environments on Laravel Cloud
laravel-herd Local dev with Laravel Herd
laravel-forge VPS hosting with Laravel Forge + DigitalOcean
cloudflare-dns DNS, CDN, SSL with Cloudflare
netlify Static site deployment with Netlify
vercel Frontend deployment with Vercel

Git

Skill Description
git-workflow Git workflow conventions for solo Laravel development
name description
vercel
Deploy frontend-only React, Next.js, or static apps to Vercel. Use when deploying standalone frontends, Next.js apps, or projects needing edge functions. Not suitable for full Laravel apps.

Vercel — Frontend Deployment

Vercel is optimised for React and Next.js projects. Use for standalone frontends or Next.js apps. Not suitable for full Laravel apps (use Laravel Cloud or Forge instead).

Deploy from Git

  1. Go to vercel.com → Add New Project → Import Git Repository
  2. Vercel auto-detects framework (Next.js, Vite, CRA, etc.)
  3. Configure build settings if needed:
Setting Value
Framework Auto-detected
Build command npm run build
Output directory dist or .next

Custom Domain

  1. Project → Settings → Domains → Add
  2. Add DNS record in Cloudflare:
CNAME  @    cname.vercel-dns.com    DNS only ☁️

Or use Vercel's nameservers for full DNS management.

Environment Variables

Project → Settings → Environment Variables. Set per environment (Production, Preview, Development).

Vercel CLI

npm install -g vercel
vercel login
vercel              # Deploy to preview
vercel --prod       # Deploy to production
vercel env list     # List env vars
vercel logs         # View function logs

Preview Deployments

Every Git branch and PR gets an automatic preview URL. Share with stakeholders before merging.

Edge Functions / API Routes

// app/api/hello/route.ts (Next.js App Router)
export async function GET() {
  return Response.json({ message: 'Hello from the edge' });
}

Deploy as edge functions — runs globally close to users.

When to Use Vercel vs Netlify

Vercel Netlify
Best for Next.js, React apps Static sites, simple SPAs
Next.js Optimised (made by Vercel) Works
Edge functions Yes Yes
Forms No Built-in
Analytics Built-in (paid) Limited

When NOT to Use Vercel

  • Full Laravel apps → use Laravel Cloud or Forge
  • Apps needing persistent server state → use a VPS
  • Apps with long-running PHP processes → not supported
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment