nimuli/docs/superpowers/plans/2026-05-15-nimuli-phase-1-p...

96 KiB

Nimuli Phase 1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the complete Nimuli Phase 1 product — short links, QR codes, Link-in-Bio, smart routing, A/B testing, analytics, billing, AI suite, REST API, and webhooks — ready for Stripe live-mode launch.

Architecture: Domain-driven Laravel 12 monolith with Octane/FrankenPHP, Livewire v3 pages, Redis-backed redirect hot-path (<10ms p95), async click events via queue, Reverb for live dashboard. Single MySQL DB with workspace_id scoping on all tenant tables.

Tech Stack: Laravel 12, PHP 8.4, Livewire v3, Alpine.js, Tailwind v4, FrankenPHP/Octane, MySQL 8, Redis 7, Reverb, Cashier/Stripe, Pest 3, PHPStan level 6.

Running commands: All php artisan and composer run via docker compose exec app. All npm on host. Aliases from .bashrc available after source ~/.bashrc.


File Map

app/
  Domains/
    Workspace/   Models, Actions, Policies, Events
    Domain/      Models, Actions, Jobs (SSL, verification)
    Link/        Models, Actions, Services/LinkCacheService
    QrCode/      Models, Actions, Services/QrRenderer
    Bio/         Models, Actions (bio pages + blocks)
    Analytics/   Models, Jobs (record, rollup, prune), Services/AnalyticsQuery
    Routing/     Services/SmartRoutingEngine, BotDetector, PixelInjector, AbTestingService
    Subscription/ Models/Plan+BillingProfile, Actions, Services/PlanLimits
    Translation/ Model, Services/TranslationService, Providers/DbLoader
    Ai/          Services/AiService, Actions (slug, ab, insight, anomaly)
    Webhook/     Models, Actions, Jobs/DeliverWebhook, Services/Dispatcher
    Api/         Resources (transformers)
  Http/
    Controllers/ RedirectController (hot-path only)
    Controllers/Api/V1/ LinkController, WorkspaceController, AnalyticsController
    Middleware/  ResolveWorkspace, EnforceLimits, LocaleFromUser
  Livewire/
    Layouts/     AppLayout, GuestLayout, PublicBioLayout
    Pages/       Dashboard, Links/*, QrCodes/*, Bio/*, Analytics/*, Workspace/*, Settings/*, Billing/*
  Console/Commands/ SyncStripe, ProvisionSsl, ResolveDomains
database/migrations/  (all tables)
routes/web.php, api.php
tests/Feature/ tests/Unit/

Task 1: Database Migrations — All Tables

Files:

  • Create: database/migrations/2026_05_15_300000_create_workspaces_table.php

  • Create: database/migrations/2026_05_15_300001_create_workspace_members_table.php

  • Create: database/migrations/2026_05_15_300002_create_workspace_invitations_table.php

  • Create: database/migrations/2026_05_15_300003_create_billing_profiles_table.php

  • Create: database/migrations/2026_05_15_300010_create_domains_table.php

  • Create: database/migrations/2026_05_15_300020_create_links_table.php

  • Create: database/migrations/2026_05_15_300021_create_link_variants_table.php

  • Create: database/migrations/2026_05_15_300030_create_qr_codes_table.php

  • Create: database/migrations/2026_05_15_300040_create_bio_pages_table.php

  • Create: database/migrations/2026_05_15_300041_create_bio_blocks_table.php

  • Create: database/migrations/2026_05_15_300050_create_clicks_table.php

  • Create: database/migrations/2026_05_15_300051_create_clicks_hourly_table.php

  • Create: database/migrations/2026_05_15_300052_create_clicks_daily_table.php

  • Create: database/migrations/2026_05_15_300053_create_clicks_by_referrer_table.php

  • Create: database/migrations/2026_05_15_300054_create_clicks_by_utm_table.php

  • Create: database/migrations/2026_05_15_300060_create_plans_table.php

  • Create: database/migrations/2026_05_15_300070_create_translations_table.php

  • Create: database/migrations/2026_05_15_300080_create_webhooks_table.php

  • Create: database/migrations/2026_05_15_300081_create_webhook_deliveries_table.php

  • Modify: database/migrations/0001_01_01_000000_create_users_table.php (add nimuli columns)

  • Step 1: Add Nimuli columns to users migration

Edit database/migrations/0001_01_01_000000_create_users_table.php, replace the up() body:

Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('ulid', 26)->unique();
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamp('email_verified_at')->nullable();
    $table->string('password');
    $table->string('two_factor_secret')->nullable();
    $table->text('two_factor_recovery')->nullable();
    $table->timestamp('two_factor_confirmed_at')->nullable();
    $table->string('locale', 5)->default('de');
    $table->enum('theme', ['dark', 'light', 'system'])->default('dark');
    $table->unsignedBigInteger('default_workspace_id')->nullable();
    $table->rememberToken();
    $table->softDeletes();
    $table->timestamps();
});
  • Step 2: Create workspaces migration
docker compose exec app php artisan make:migration create_workspaces_table

Fill it:

Schema::create('workspaces', function (Blueprint $table) {
    $table->id();
    $table->string('ulid', 26)->unique();
    $table->string('name');
    $table->string('slug', 64);
    $table->foreignId('owner_id')->constrained('users')->cascadeOnDelete();
    $table->foreignId('plan_id')->nullable()->constrained('plans')->nullOnDelete();
    $table->timestamp('trial_ends_at')->nullable();
    $table->json('branding')->nullable(); // {logo, primary_color, accent_color}
    $table->string('stripe_id')->nullable()->index();
    $table->json('stripe_customer_metadata')->nullable();
    $table->softDeletes();
    $table->timestamps();
});
  • Step 3: Create workspace_members and workspace_invitations migrations
docker compose exec app php artisan make:migration create_workspace_members_table
docker compose exec app php artisan make:migration create_workspace_invitations_table

workspace_members:

Schema::create('workspace_members', function (Blueprint $table) {
    $table->id();
    $table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->enum('role', ['owner', 'admin', 'editor', 'viewer'])->default('editor');
    $table->foreignId('invited_by')->nullable()->constrained('users')->nullOnDelete();
    $table->timestamp('joined_at')->nullable();
    $table->timestamps();
    $table->unique(['workspace_id', 'user_id']);
});

workspace_invitations:

Schema::create('workspace_invitations', function (Blueprint $table) {
    $table->id();
    $table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
    $table->string('email');
    $table->enum('role', ['admin', 'editor', 'viewer'])->default('editor');
    $table->string('token', 64)->unique();
    $table->foreignId('invited_by')->constrained('users')->cascadeOnDelete();
    $table->timestamp('expires_at');
    $table->timestamp('accepted_at')->nullable();
    $table->timestamps();
});
  • Step 4: Create billing_profiles and domains migrations

billing_profiles:

Schema::create('billing_profiles', function (Blueprint $table) {
    $table->id();
    $table->foreignId('workspace_id')->unique()->constrained()->cascadeOnDelete();
    $table->foreignId('billing_user_id')->constrained('users')->cascadeOnDelete();
    $table->string('company_name')->nullable();
    $table->string('vat_number', 30)->nullable();
    $table->string('address_line_1');
    $table->string('address_line_2')->nullable();
    $table->string('city');
    $table->string('postal_code', 20);
    $table->string('country', 2);
    $table->string('email_billing');
    $table->timestamps();
});

domains:

Schema::create('domains', function (Blueprint $table) {
    $table->id();
    $table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
    $table->string('hostname', 253)->unique();
    $table->enum('type', ['short', 'bio', 'both'])->default('short');
    $table->timestamp('verified_at')->nullable();
    $table->string('verification_token', 64)->nullable();
    $table->enum('ssl_status', ['pending', 'active', 'failed'])->default('pending');
    $table->timestamp('ssl_issued_at')->nullable();
    $table->timestamp('ssl_expires_at')->nullable();
    $table->boolean('is_default')->default(false);
    $table->timestamps();
    $table->index(['workspace_id', 'hostname']);
});
  • Step 5: Create links and link_variants migrations

links:

Schema::create('links', function (Blueprint $table) {
    $table->id();
    $table->string('ulid', 26)->unique();
    $table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
    $table->foreignId('domain_id')->nullable()->constrained()->nullOnDelete();
    $table->string('slug', 64);
    $table->text('target_url');
    $table->string('title')->nullable();
    $table->text('description')->nullable();
    $table->string('favicon_url')->nullable();
    $table->enum('status', ['active', 'disabled', 'expired'])->default('active');
    $table->string('password', 255)->nullable();
    $table->timestamp('expires_at')->nullable();
    $table->unsignedBigInteger('click_limit')->nullable();
    $table->json('rules')->nullable();
    $table->json('pixel_config')->nullable();
    $table->json('tags')->nullable();
    $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
    $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
    $table->softDeletes();
    $table->timestamps();
    $table->unique(['domain_id', 'slug']);
    $table->index('workspace_id');
    $table->index('status');
});

link_variants:

Schema::create('link_variants', function (Blueprint $table) {
    $table->id();
    $table->foreignId('link_id')->constrained()->cascadeOnDelete();
    $table->string('label', 64);
    $table->text('target_url');
    $table->unsignedTinyInteger('weight')->default(50);
    $table->unsignedBigInteger('clicks_count')->default(0);
    $table->unsignedBigInteger('conversions_count')->default(0);
    $table->timestamps();
});
  • Step 6: Create QR, Bio, and Analytics migrations
docker compose exec app php artisan make:migration create_qr_codes_table
docker compose exec app php artisan make:migration create_bio_pages_table
docker compose exec app php artisan make:migration create_bio_blocks_table
docker compose exec app php artisan make:migration create_clicks_table
docker compose exec app php artisan make:migration create_clicks_hourly_table
docker compose exec app php artisan make:migration create_clicks_daily_table
docker compose exec app php artisan make:migration create_clicks_by_referrer_table
docker compose exec app php artisan make:migration create_clicks_by_utm_table

qr_codes:

Schema::create('qr_codes', function (Blueprint $table) {
    $table->id();
    $table->string('ulid', 26)->unique();
    $table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
    $table->foreignId('link_id')->nullable()->constrained()->nullOnDelete();
    $table->enum('type', ['url', 'vcard', 'wifi', 'pdf', 'mp3', 'text'])->default('url');
    $table->json('payload');
    $table->json('style')->nullable();
    $table->boolean('is_dynamic')->default(true);
    $table->unsignedBigInteger('scan_limit')->nullable();
    $table->timestamp('expires_at')->nullable();
    $table->softDeletes();
    $table->timestamps();
    $table->index('workspace_id');
});

bio_pages:

Schema::create('bio_pages', function (Blueprint $table) {
    $table->id();
    $table->string('ulid', 26)->unique();
    $table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
    $table->foreignId('domain_id')->nullable()->constrained()->nullOnDelete();
    $table->string('slug', 64);
    $table->json('title');  // translatable
    $table->json('description')->nullable();
    $table->string('og_image')->nullable();
    $table->json('theme')->nullable();
    $table->boolean('is_published')->default(false);
    $table->softDeletes();
    $table->timestamps();
    $table->unique(['domain_id', 'slug']);
    $table->index('workspace_id');
});

bio_blocks:

Schema::create('bio_blocks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('bio_page_id')->constrained()->cascadeOnDelete();
    $table->unsignedSmallInteger('position')->default(0);
    $table->string('type', 32);
    $table->json('config');
    $table->timestamps();
    $table->index(['bio_page_id', 'position']);
});

clicks (append-only, no updates):

Schema::create('clicks', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->foreignId('link_id')->constrained()->cascadeOnDelete();
    $table->foreignId('variant_id')->nullable()->constrained('link_variants')->nullOnDelete();
    $table->unsignedBigInteger('workspace_id');
    $table->timestamp('clicked_at')->useCurrent();
    $table->string('ip_hash', 64);
    $table->string('country', 2)->nullable();
    $table->string('city', 64)->nullable();
    $table->enum('device', ['desktop', 'mobile', 'tablet', 'bot', 'unknown'])->default('unknown');
    $table->string('os', 32)->nullable();
    $table->string('browser', 32)->nullable();
    $table->string('referrer_host', 253)->nullable();
    $table->string('referrer_path', 2048)->nullable();
    $table->string('utm_source', 128)->nullable();
    $table->string('utm_medium', 128)->nullable();
    $table->string('utm_campaign', 128)->nullable();
    $table->string('utm_content', 128)->nullable();
    $table->string('utm_term', 128)->nullable();
    $table->string('language', 10)->nullable();
    $table->index('clicked_at');
    $table->index(['link_id', 'clicked_at']);
    $table->index(['workspace_id', 'clicked_at']);
});

clicks_hourly:

Schema::create('clicks_hourly', function (Blueprint $table) {
    $table->id();
    $table->foreignId('link_id')->constrained()->cascadeOnDelete();
    $table->timestamp('hour');
    $table->string('country', 2)->nullable();
    $table->enum('device', ['desktop', 'mobile', 'tablet', 'bot', 'unknown'])->nullable();
    $table->unsignedBigInteger('count')->default(0);
    $table->unsignedBigInteger('unique_visitors')->default(0);
    $table->unique(['link_id', 'hour', 'country', 'device']);
    $table->index(['link_id', 'hour']);
});

clicks_daily (same pattern, day instead of hour), clicks_by_referrer (add referrer_host), clicks_by_utm (add utm_source, utm_medium, utm_campaign).

  • Step 7: Create plans, translations, webhooks migrations

plans:

Schema::create('plans', function (Blueprint $table) {
    $table->id();
    $table->string('name', 32)->unique();
    $table->string('stripe_price_id')->nullable();
    $table->unsignedInteger('monthly_price_cents')->default(0);
    $table->unsignedSmallInteger('workspace_limit')->default(1);
    $table->unsignedSmallInteger('member_limit')->default(1);
    $table->unsignedSmallInteger('custom_domain_limit')->default(0);
    $table->unsignedInteger('link_limit')->default(50);
    $table->unsignedBigInteger('click_limit_monthly')->default(1000);
    $table->json('features')->nullable();
    $table->timestamps();
});

translations:

Schema::create('translations', function (Blueprint $table) {
    $table->id();
    $table->string('locale', 5);
    $table->string('namespace', 32)->default('*');
    $table->string('group', 64);
    $table->string('key', 255);
    $table->text('value');
    $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
    $table->timestamps();
    $table->unique(['locale', 'namespace', 'group', 'key']);
    $table->index(['locale', 'group']);
});

webhooks + webhook_deliveries follow spec schema.

  • Step 8: Run fresh migration
docker compose exec app php artisan migrate:fresh

Expected: All tables created, no errors. If foreign key errors, check migration order (plans must exist before workspaces).

  • Step 9: Commit
git add database/migrations/
git commit -m "feat(db): add all Phase 1 migrations"

Task 2: Core Models

Files:

  • Create: app/Domains/Workspace/Models/Workspace.php

  • Create: app/Domains/Workspace/Models/WorkspaceMember.php

  • Create: app/Domains/Workspace/Models/WorkspaceInvitation.php

  • Create: app/Domains/Domain/Models/Domain.php

  • Create: app/Domains/Link/Models/Link.php

  • Create: app/Domains/Link/Models/LinkVariant.php

  • Create: app/Domains/Analytics/Models/Click.php

  • Create: app/Domains/Subscription/Models/Plan.php

  • Modify: app/Models/User.php

  • Step 1: Create directory structure

mkdir -p app/Domains/{Workspace/Models,Domain/Models,Link/Models,QrCode/Models,Bio/Models,Analytics/Models,Routing/Services,Subscription/Models,Translation,Ai/Services,Webhook/Models,Api/Resources}
mkdir -p app/Domains/{Workspace/{Actions,Policies},Domain/{Actions,Jobs},Link/{Actions,Services},Analytics/{Jobs,Services},Subscription/{Actions,Services},Webhook/{Actions,Jobs,Services}}
  • Step 2: Workspace model

app/Domains/Workspace/Models/Workspace.php:

<?php

namespace App\Domains\Workspace\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Concerns\HasUlids;

class Workspace extends Model
{
    use SoftDeletes, HasUlids;

    protected $guarded = ['id', 'owner_id'];

    protected $casts = [
        'branding' => 'array',
        'stripe_customer_metadata' => 'array',
        'trial_ends_at' => 'datetime',
    ];

    public function owner(): BelongsTo
    {
        return $this->belongsTo(\App\Models\User::class, 'owner_id');
    }

    public function members(): HasMany
    {
        return $this->hasMany(WorkspaceMember::class);
    }

    public function invitations(): HasMany
    {
        return $this->hasMany(WorkspaceInvitation::class);
    }

    public function links(): HasMany
    {
        return $this->hasMany(\App\Domains\Link\Models\Link::class);
    }

    public function domains(): HasMany
    {
        return $this->hasMany(\App\Domains\Domain\Models\Domain::class);
    }

    public function plan(): BelongsTo
    {
        return $this->belongsTo(\App\Domains\Subscription\Models\Plan::class);
    }
}
  • Step 3: Link model

app/Domains/Link/Models/Link.php:

<?php

namespace App\Domains\Link\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Concerns\HasUlids;

class Link extends Model
{
    use SoftDeletes, HasUlids;

    protected $guarded = ['id', 'workspace_id', 'created_by'];

    protected $casts = [
        'rules' => 'array',
        'pixel_config' => 'array',
        'tags' => 'array',
        'expires_at' => 'datetime',
    ];

    public function workspace(): BelongsTo
    {
        return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
    }

    public function domain(): BelongsTo
    {
        return $this->belongsTo(\App\Domains\Domain\Models\Domain::class);
    }

    public function variants(): HasMany
    {
        return $this->hasMany(LinkVariant::class);
    }

    public function clicks(): HasMany
    {
        return $this->hasMany(\App\Domains\Analytics\Models\Click::class);
    }

    public function isActive(): bool
    {
        if ($this->status !== 'active') {
            return false;
        }
        if ($this->expires_at && $this->expires_at->isPast()) {
            return false;
        }
        return true;
    }
}
  • Step 4: Click model (append-only)

app/Domains/Analytics/Models/Click.php:

<?php

namespace App\Domains\Analytics\Models;

use Illuminate\Database\Eloquent\Model;

class Click extends Model
{
    public $timestamps = false;

    protected $guarded = [];

    protected $casts = [
        'clicked_at' => 'datetime',
    ];

    // Prevent updates — clicks are append-only
    public function save(array $options = []): bool
    {
        if ($this->exists) {
            throw new \LogicException('Click records are append-only.');
        }
        return parent::save($options);
    }

    public function link()
    {
        return $this->belongsTo(\App\Domains\Link\Models\Link::class);
    }
}
  • Step 5: Update User model

Add to app/Models/User.php:

use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\SoftDeletes;

// In class body:
use HasUlids, SoftDeletes;

protected $guarded = ['id'];

protected $casts = [
    // existing casts...
    'two_factor_confirmed_at' => 'datetime',
];

public function workspaces()
{
    return $this->hasMany(\App\Domains\Workspace\Models\Workspace::class, 'owner_id');
}

public function defaultWorkspace()
{
    return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class, 'default_workspace_id');
}

public function workspaceMemberships()
{
    return $this->hasMany(\App\Domains\Workspace\Models\WorkspaceMember::class);
}
  • Step 6: Run tests (baseline)
docker compose exec app vendor/bin/pest --no-coverage

Expected: Existing tests pass. Fix any failures before continuing.

  • Step 7: Commit
git add app/Domains/ app/Models/User.php
git commit -m "feat(models): add core domain models"

Task 3: Auth Hardening

Files:

  • Modify: config/auth.php

  • Modify: config/hashing.php

  • Create: app/Http/Middleware/LocaleFromUser.php

  • Modify: bootstrap/app.php (register middleware)

  • Modify: routes/auth.php (add rate limiting)

  • Step 1: Write failing test for Argon2id hashing

tests/Feature/Auth/HashingTest.php:

<?php

use Illuminate\Support\Facades\Hash;

it('uses argon2id for password hashing', function () {
    $hash = Hash::make('password');
    expect(password_get_info($hash)['algoName'])->toBe('argon2id');
});

Run: docker compose exec app vendor/bin/pest tests/Feature/Auth/HashingTest.php -v Expected: FAIL — default driver is bcrypt

  • Step 2: Configure Argon2id

config/hashing.php — change driver:

'driver' => 'argon2id',

Run: docker compose exec app vendor/bin/pest tests/Feature/Auth/HashingTest.php -v Expected: PASS

  • Step 3: Write failing test for login rate limiting

tests/Feature/Auth/LoginRateLimitTest.php:

<?php

it('throttles login after 5 failed attempts', function () {
    for ($i = 0; $i < 5; $i++) {
        $this->post('/login', ['email' => 'test@test.com', 'password' => 'wrong']);
    }
    $response = $this->post('/login', ['email' => 'test@test.com', 'password' => 'wrong']);
    $response->assertStatus(429);
});

Run: docker compose exec app vendor/bin/pest tests/Feature/Auth/LoginRateLimitTest.php -v Expected: FAIL (default Breeze may allow more attempts)

  • Step 4: Create LocaleFromUser middleware

app/Http/Middleware/LocaleFromUser.php:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;

class LocaleFromUser
{
    public function handle(Request $request, Closure $next)
    {
        if ($user = $request->user()) {
            App::setLocale($user->locale ?? config('app.locale'));
        }
        return $next($request);
    }
}

Register in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\LocaleFromUser::class,
    ]);
})
  • Step 5: Commit
git add config/hashing.php app/Http/Middleware/LocaleFromUser.php bootstrap/app.php tests/Feature/Auth/
git commit -m "feat(auth): argon2id hashing, locale middleware, login rate limiting"

Task 4: Workspace Domain — Create, Middleware, Policies

Files:

  • Create: app/Domains/Workspace/Actions/CreateWorkspace.php

  • Create: app/Domains/Workspace/Actions/InviteMember.php

  • Create: app/Domains/Workspace/Actions/AcceptInvitation.php

  • Create: app/Domains/Workspace/Policies/WorkspacePolicy.php

  • Create: app/Http/Middleware/ResolveWorkspace.php

  • Create: tests/Feature/Workspace/CreateWorkspaceTest.php

  • Step 1: Write failing test

tests/Feature/Workspace/CreateWorkspaceTest.php:

<?php

use App\Domains\Workspace\Actions\CreateWorkspace;
use App\Models\User;

it('creates a workspace and assigns owner as member', function () {
    $user = User::factory()->create();

    $workspace = (new CreateWorkspace)->handle($user, ['name' => 'Acme Corp']);

    expect($workspace->name)->toBe('Acme Corp')
        ->and($workspace->owner_id)->toBe($user->id)
        ->and($workspace->ulid)->toHaveLength(26);

    $this->assertDatabaseHas('workspace_members', [
        'workspace_id' => $workspace->id,
        'user_id' => $user->id,
        'role' => 'owner',
    ]);
});

Run: docker compose exec app vendor/bin/pest tests/Feature/Workspace/CreateWorkspaceTest.php -v Expected: FAIL — class not found

  • Step 2: Implement CreateWorkspace action

app/Domains/Workspace/Actions/CreateWorkspace.php:

<?php

namespace App\Domains\Workspace\Actions;

use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceMember;
use App\Models\User;
use Illuminate\Support\Str;

class CreateWorkspace
{
    public function handle(User $owner, array $data): Workspace
    {
        $workspace = Workspace::create([
            'ulid' => Str::ulid(),
            'name' => $data['name'],
            'slug' => Str::slug($data['name']),
            'owner_id' => $owner->id,
        ]);

        WorkspaceMember::create([
            'workspace_id' => $workspace->id,
            'user_id' => $owner->id,
            'role' => 'owner',
            'joined_at' => now(),
        ]);

        if (! $owner->default_workspace_id) {
            $owner->update(['default_workspace_id' => $workspace->id]);
        }

        return $workspace;
    }
}

Run: docker compose exec app vendor/bin/pest tests/Feature/Workspace/CreateWorkspaceTest.php -v Expected: PASS

  • Step 3: Create ResolveWorkspace middleware

app/Http/Middleware/ResolveWorkspace.php:

<?php

namespace App\Http\Middleware;

use App\Domains\Workspace\Models\Workspace;
use Closure;
use Illuminate\Http\Request;

class ResolveWorkspace
{
    public function handle(Request $request, Closure $next)
    {
        $ulid = $request->route('workspace');

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

        $workspace = Workspace::where('ulid', $ulid)->firstOrFail();

        // 404 instead of 403 — don't leak existence across workspace boundaries
        if (! $request->user()?->workspaceMemberships()->where('workspace_id', $workspace->id)->exists()) {
            abort(404);
        }

        $request->merge(['current_workspace' => $workspace]);
        app()->instance('current_workspace', $workspace);

        return $next($request);
    }
}
  • Step 4: Write and implement WorkspacePolicy

app/Domains/Workspace/Policies/WorkspacePolicy.php:

<?php

namespace App\Domains\Workspace\Policies;

use App\Domains\Workspace\Models\Workspace;
use App\Models\User;

class WorkspacePolicy
{
    public function view(User $user, Workspace $workspace): bool
    {
        return $this->hasRole($user, $workspace, ['owner', 'admin', 'editor', 'viewer']);
    }

    public function create(User $user): bool
    {
        return true;
    }

    public function update(User $user, Workspace $workspace): bool
    {
        return $this->hasRole($user, $workspace, ['owner', 'admin']);
    }

    public function delete(User $user, Workspace $workspace): bool
    {
        return $this->hasRole($user, $workspace, ['owner']);
    }

    public function manageBilling(User $user, Workspace $workspace): bool
    {
        return $this->hasRole($user, $workspace, ['owner']);
    }

    private function hasRole(User $user, Workspace $workspace, array $roles): bool
    {
        return $workspace->members()
            ->where('user_id', $user->id)
            ->whereIn('role', $roles)
            ->exists();
    }
}

Register in app/Providers/AppServiceProvider.php:

use Illuminate\Support\Facades\Gate;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Policies\WorkspacePolicy;

Gate::policy(Workspace::class, WorkspacePolicy::class);
  • Step 5: Run all workspace tests
docker compose exec app vendor/bin/pest tests/Feature/Workspace/ -v

Expected: All PASS

  • Step 6: Commit
git add app/Domains/Workspace/ app/Http/Middleware/ResolveWorkspace.php tests/Feature/Workspace/
git commit -m "feat(workspace): create action, middleware, policies"

Files:

  • Create: app/Domains/Link/Services/LinkCacheService.php

  • Create: app/Domains/Link/Actions/CreateLink.php

  • Create: app/Domains/Link/Actions/UpdateLink.php

  • Create: app/Domains/Link/Actions/DeleteLink.php

  • Create: tests/Unit/Link/LinkCacheServiceTest.php

  • Step 1: Write failing test for link cache

tests/Unit/Link/LinkCacheServiceTest.php:

<?php

use App\Domains\Link\Services\LinkCacheService;
use Illuminate\Support\Facades\Redis;

it('stores link data in redis hash', function () {
    $service = new LinkCacheService;

    $service->put('testslug', [
        'target' => 'https://example.com',
        'status' => 'active',
        'workspace_id' => 1,
    ]);

    $data = $service->get('testslug');

    expect($data['target'])->toBe('https://example.com')
        ->and($data['status'])->toBe('active');
});

it('returns null for unknown slug', function () {
    $service = new LinkCacheService;
    expect($service->get('nonexistent-' . uniqid()))->toBeNull();
});

Run: docker compose exec app vendor/bin/pest tests/Unit/Link/LinkCacheServiceTest.php -v Expected: FAIL

  • Step 2: Implement LinkCacheService

app/Domains/Link/Services/LinkCacheService.php:

<?php

namespace App\Domains\Link\Services;

use Illuminate\Support\Facades\Redis;

class LinkCacheService
{
    private string $prefix = 'link:';

    public function key(string $slug, ?int $domainId = null): string
    {
        return $domainId
            ? "{$this->prefix}{$domainId}:{$slug}"
            : "{$this->prefix}{$slug}";
    }

    public function put(string $slug, array $data, ?int $domainId = null): void
    {
        $key = $this->key($slug, $domainId);
        Redis::hMSet($key, $data);
        // TTL: 24 hours for active links, shorter for expired
        Redis::expire($key, 86400);
    }

    public function get(string $slug, ?int $domainId = null): ?array
    {
        $key = $this->key($slug, $domainId);
        $data = Redis::hGetAll($key);
        return empty($data) ? null : $data;
    }

    public function forget(string $slug, ?int $domainId = null): void
    {
        Redis::del($this->key($slug, $domainId));
    }

    public function warmFromLink(\App\Domains\Link\Models\Link $link): void
    {
        $this->put($link->slug, [
            'target' => $link->target_url,
            'status' => $link->status,
            'rules_json' => $link->rules ? json_encode($link->rules) : '',
            'expires_at' => $link->expires_at?->timestamp ?? '',
            'workspace_id' => $link->workspace_id,
            'pixel_config' => $link->pixel_config ? json_encode($link->pixel_config) : '',
            'link_id' => $link->id,
        ], $link->domain_id);
    }
}

Run: docker compose exec app vendor/bin/pest tests/Unit/Link/LinkCacheServiceTest.php -v Expected: PASS

  • Step 3: Implement CreateLink action

app/Domains/Link/Actions/CreateLink.php:

<?php

namespace App\Domains\Link\Actions;

use App\Domains\Link\Models\Link;
use App\Domains\Link\Services\LinkCacheService;
use App\Domains\Workspace\Models\Workspace;
use App\Models\User;
use Illuminate\Support\Str;

class CreateLink
{
    public function __construct(private LinkCacheService $cache) {}

    public function handle(Workspace $workspace, User $creator, array $data): Link
    {
        $link = Link::create([
            'ulid' => Str::ulid(),
            'workspace_id' => $workspace->id,
            'domain_id' => $data['domain_id'] ?? null,
            'slug' => $data['slug'] ?? $this->generateSlug(),
            'target_url' => $data['target_url'],
            'title' => $data['title'] ?? null,
            'description' => $data['description'] ?? null,
            'status' => 'active',
            'expires_at' => $data['expires_at'] ?? null,
            'click_limit' => $data['click_limit'] ?? null,
            'rules' => $data['rules'] ?? null,
            'pixel_config' => $data['pixel_config'] ?? null,
            'tags' => $data['tags'] ?? null,
            'created_by' => $creator->id,
            'updated_by' => $creator->id,
        ]);

        $this->cache->warmFromLink($link);

        return $link;
    }

    private function generateSlug(int $length = 6): string
    {
        do {
            $slug = Str::random($length);
        } while (Link::where('slug', $slug)->whereNull('domain_id')->exists());

        return $slug;
    }
}
  • Step 4: Run link tests
docker compose exec app vendor/bin/pest tests/ --filter=link -v

Expected: All PASS

  • Step 5: Commit
git add app/Domains/Link/ tests/Unit/Link/ tests/Feature/Link/
git commit -m "feat(link): create action, cache service, slug generation"

Task 6: Redirect Controller (Hot Path)

Files:

  • Create: app/Http/Controllers/RedirectController.php

  • Modify: routes/web.php

  • Create: tests/Feature/Redirect/RedirectTest.php

  • Step 1: Write failing redirect test

tests/Feature/Redirect/RedirectTest.php:

<?php

use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace;
use App\Models\User;

it('redirects to target url for active link', function () {
    $user = User::factory()->create();
    $workspace = \App\Domains\Workspace\Actions\CreateWorkspace::new()->handle($user, ['name' => 'Test']);
    $link = Link::factory()->create([
        'workspace_id' => $workspace->id,
        'slug' => 'abc123',
        'target_url' => 'https://example.com',
        'status' => 'active',
    ]);

    $response = $this->withHeaders(['Host' => config('app.short_link_domain', 'nimu.li')])
                     ->get('/abc123');

    $response->assertRedirect('https://example.com');
    $response->assertStatus(302);
});

it('returns 404 for unknown slug', function () {
    $response = $this->withHeaders(['Host' => 'nimu.li'])->get('/nonexistent-xyz');
    $response->assertStatus(404);
});

it('returns 410 for disabled link', function () {
    $user = User::factory()->create();
    $workspace = \App\Domains\Workspace\Actions\CreateWorkspace::new()->handle($user, ['name' => 'Test']);
    Link::factory()->create([
        'workspace_id' => $workspace->id,
        'slug' => 'disabled1',
        'target_url' => 'https://example.com',
        'status' => 'disabled',
    ]);

    $response = $this->withHeaders(['Host' => 'nimu.li'])->get('/disabled1');
    $response->assertStatus(410);
});

Run: docker compose exec app vendor/bin/pest tests/Feature/Redirect/RedirectTest.php -v Expected: FAIL

  • Step 2: Implement RedirectController

app/Http/Controllers/RedirectController.php:

<?php

namespace App\Http\Controllers;

use App\Domains\Analytics\Jobs\RecordClickJob;
use App\Domains\Link\Models\Link;
use App\Domains\Link\Services\LinkCacheService;
use Illuminate\Http\Request;

class RedirectController extends Controller
{
    public function __construct(private LinkCacheService $cache) {}

    public function __invoke(Request $request, string $slug)
    {
        // 1. Redis-first lookup
        $data = $this->cache->get($slug);

        if ($data === null) {
            // Cache miss — try DB
            $link = Link::where('slug', $slug)
                ->whereNull('domain_id') // default domain
                ->first();

            if (! $link) {
                abort(404);
            }

            $this->cache->warmFromLink($link);
            $data = $this->cache->get($slug);
        }

        // 2. Status check
        if (($data['status'] ?? '') !== 'active') {
            abort(410);
        }

        // 3. Expiry check
        if (! empty($data['expires_at']) && (int) $data['expires_at'] < time()) {
            abort(410);
        }

        // 4. Fire-and-forget click event
        RecordClickJob::dispatch((int) $data['link_id'], $request->ip(), $request->userAgent(), $request->headers->all())
            ->onQueue('clicks');

        // 5. 302 redirect
        return redirect($data['target'], 302);
    }
}
  • Step 3: Add route for redirect service

In routes/web.php, add at top (before other routes):

// Redirect service — handles nimu.li and custom domains
Route::domain(config('SHORT_LINK_DOMAIN', 'nimu.li'))->group(function () {
    Route::get('/{slug}', \App\Http\Controllers\RedirectController::class)
        ->where('slug', '[a-zA-Z0-9_-]+');
});

Also add fallback route on main domain to RedirectController for custom domain slugs:

Route::get('/{slug}', \App\Http\Controllers\RedirectController::class)
    ->where('slug', '[a-zA-Z0-9_-]+')
    ->name('redirect');
  • Step 4: Create Link factory
docker compose exec app php artisan make:factory LinkFactory --model="App\Domains\Link\Models\Link"

database/factories/LinkFactory.php:

public function definition(): array
{
    return [
        'ulid' => \Illuminate\Support\Str::ulid(),
        'workspace_id' => \App\Domains\Workspace\Models\Workspace::factory(),
        'slug' => \Illuminate\Support\Str::random(6),
        'target_url' => fake()->url(),
        'status' => 'active',
    ];
}
  • Step 5: Run redirect tests
docker compose exec app vendor/bin/pest tests/Feature/Redirect/ -v

Expected: All PASS

  • Step 6: Commit
git add app/Http/Controllers/RedirectController.php routes/web.php database/factories/ tests/Feature/Redirect/
git commit -m "feat(redirect): hot-path redirect controller with Redis cache"

Task 7: Click Recording

Files:

  • Create: app/Domains/Analytics/Jobs/RecordClickJob.php

  • Create: app/Domains/Analytics/Events/ClickRecorded.php

  • Create: tests/Feature/Analytics/RecordClickTest.php

  • Step 1: Write failing test

tests/Feature/Analytics/RecordClickTest.php:

<?php

use App\Domains\Analytics\Jobs\RecordClickJob;
use App\Domains\Analytics\Models\Click;
use App\Domains\Link\Models\Link;

it('records a click to the database', function () {
    $link = Link::factory()->create();

    RecordClickJob::dispatchSync($link->id, '1.2.3.4', 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0)', []);

    $this->assertDatabaseCount('clicks', 1);
    $this->assertDatabaseHas('clicks', [
        'link_id' => $link->id,
        'country' => null,
        'device' => 'mobile',
    ]);
});

it('hashes IP address before storage', function () {
    $link = Link::factory()->create();

    RecordClickJob::dispatchSync($link->id, '1.2.3.4', 'test-agent', []);

    $click = Click::first();
    expect($click->ip_hash)->not->toBe('1.2.3.4')
        ->and(strlen($click->ip_hash))->toBe(64);
});

Run: docker compose exec app vendor/bin/pest tests/Feature/Analytics/RecordClickTest.php -v Expected: FAIL

  • Step 2: Implement RecordClickJob

app/Domains/Analytics/Jobs/RecordClickJob.php:

<?php

namespace App\Domains\Analytics\Jobs;

use App\Domains\Analytics\Models\Click;
use App\Domains\Link\Models\Link;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Cache;

class RecordClickJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function __construct(
        public readonly int $linkId,
        public readonly string $ip,
        public readonly ?string $userAgent,
        public readonly array $headers,
    ) {}

    public function handle(): void
    {
        $link = Link::find($this->linkId);
        if (! $link) {
            return;
        }

        $ipHash = $this->hashIp($this->ip);
        $device = $this->detectDevice($this->userAgent ?? '');

        Click::create([
            'link_id' => $this->linkId,
            'workspace_id' => $link->workspace_id,
            'clicked_at' => now(),
            'ip_hash' => $ipHash,
            'device' => $device,
            'os' => $this->detectOs($this->userAgent ?? ''),
            'browser' => $this->detectBrowser($this->userAgent ?? ''),
            'referrer_host' => $this->extractHost($this->headers['referer'][0] ?? null),
            'utm_source' => $this->headers['utm_source'][0] ?? null,
            'utm_medium' => $this->headers['utm_medium'][0] ?? null,
            'utm_campaign' => $this->headers['utm_campaign'][0] ?? null,
            'language' => substr($this->headers['accept-language'][0] ?? 'de', 0, 10),
        ]);

        // Increment Redis counter
        $date = now()->format('Y-m-d');
        \Illuminate\Support\Facades\Redis::incr("clicks:{$this->linkId}:{$date}");
        \Illuminate\Support\Facades\Redis::incr("clicks:{$link->workspace_id}:monthly:" . now()->format('Ym'));
    }

    private function hashIp(string $ip): string
    {
        $salt = Cache::remember('ip_salt:' . today()->format('Y-m-d'), 86400, fn() => bin2hex(random_bytes(16)));
        return hash('sha256', $ip . $salt);
    }

    private function detectDevice(string $ua): string
    {
        if (str_contains($ua, 'bot') || str_contains($ua, 'crawler')) {
            return 'bot';
        }
        if (preg_match('/Mobile|iPhone|Android.*Mobile|Windows Phone/', $ua)) {
            return 'mobile';
        }
        if (preg_match('/iPad|Android(?!.*Mobile)|Tablet/', $ua)) {
            return 'tablet';
        }
        if (strlen($ua) === 0) {
            return 'unknown';
        }
        return 'desktop';
    }

    private function detectOs(string $ua): ?string
    {
        if (str_contains($ua, 'Windows')) return 'windows';
        if (str_contains($ua, 'Mac OS')) return 'macos';
        if (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) return 'ios';
        if (str_contains($ua, 'Android')) return 'android';
        if (str_contains($ua, 'Linux')) return 'linux';
        return null;
    }

    private function detectBrowser(string $ua): ?string
    {
        if (str_contains($ua, 'Chrome')) return 'chrome';
        if (str_contains($ua, 'Firefox')) return 'firefox';
        if (str_contains($ua, 'Safari') && ! str_contains($ua, 'Chrome')) return 'safari';
        if (str_contains($ua, 'Edge')) return 'edge';
        return null;
    }

    private function extractHost(?string $url): ?string
    {
        if (! $url) return null;
        $parsed = parse_url($url);
        return $parsed['host'] ?? null;
    }
}
  • Step 3: Run analytics tests
docker compose exec app vendor/bin/pest tests/Feature/Analytics/ -v

Expected: All PASS

  • Step 4: Commit
git add app/Domains/Analytics/ tests/Feature/Analytics/
git commit -m "feat(analytics): click recording job with IP hashing, device detection"

Task 8: Smart Routing Engine

Files:

  • Create: app/Domains/Routing/Services/SmartRoutingEngine.php

  • Create: tests/Unit/Routing/SmartRoutingEngineTest.php

  • Step 1: Write failing tests

tests/Unit/Routing/SmartRoutingEngineTest.php:

<?php

use App\Domains\Routing\Services\SmartRoutingEngine;

it('returns fallback when no rules match', function () {
    $engine = new SmartRoutingEngine;
    $rules = [
        'rules' => [],
        'fallback' => 'https://fallback.com',
    ];
    $ctx = ['country' => 'US', 'device' => 'desktop'];

    expect($engine->resolve($rules, $ctx))->toBe('https://fallback.com');
});

it('matches device rule', function () {
    $engine = new SmartRoutingEngine;
    $rules = [
        'rules' => [
            [
                'id' => 'r1', 'priority' => 1,
                'conditions' => [['field' => 'device', 'op' => 'in', 'value' => ['mobile']]],
                'match' => 'all',
                'target' => 'https://mobile.com',
            ],
        ],
        'fallback' => 'https://fallback.com',
    ];

    expect($engine->resolve($rules, ['device' => 'mobile']))->toBe('https://mobile.com');
    expect($engine->resolve($rules, ['device' => 'desktop']))->toBe('https://fallback.com');
});

it('matches country IN condition', function () {
    $engine = new SmartRoutingEngine;
    $rules = [
        'rules' => [
            [
                'id' => 'r1', 'priority' => 1,
                'conditions' => [['field' => 'country', 'op' => 'in', 'value' => ['DE', 'AT', 'CH']]],
                'match' => 'all',
                'target' => 'https://dach.com',
            ],
        ],
        'fallback' => 'https://fallback.com',
    ];

    expect($engine->resolve($rules, ['country' => 'DE']))->toBe('https://dach.com');
    expect($engine->resolve($rules, ['country' => 'US']))->toBe('https://fallback.com');
});

Run: docker compose exec app vendor/bin/pest tests/Unit/Routing/ -v Expected: FAIL

  • Step 2: Implement SmartRoutingEngine

app/Domains/Routing/Services/SmartRoutingEngine.php:

<?php

namespace App\Domains\Routing\Services;

class SmartRoutingEngine
{
    public function resolve(array $config, array $context): string
    {
        $rules = collect($config['rules'] ?? [])
            ->sortBy('priority');

        foreach ($rules as $rule) {
            if ($this->ruleMatches($rule, $context)) {
                return $rule['target'];
            }
        }

        return $config['fallback'];
    }

    private function ruleMatches(array $rule, array $context): bool
    {
        $conditions = $rule['conditions'] ?? [];
        $matchMode = $rule['match'] ?? 'all';

        $results = array_map(
            fn($condition) => $this->evaluateCondition($condition, $context),
            $conditions
        );

        return $matchMode === 'all'
            ? ! in_array(false, $results, true)
            : in_array(true, $results, true);
    }

    private function evaluateCondition(array $condition, array $context): bool
    {
        $field = $condition['field'];
        $op = $condition['op'];
        $expected = $condition['value'];
        $actual = $context[$field] ?? null;

        return match ($op) {
            'equals' => $actual === $expected,
            'not_equals' => $actual !== $expected,
            'in' => in_array($actual, (array) $expected, true),
            'not_in' => ! in_array($actual, (array) $expected, true),
            'contains' => str_contains((string) $actual, (string) $expected),
            'starts_with' => str_starts_with((string) $actual, (string) $expected),
            'between' => $actual >= $expected[0] && $actual <= $expected[1],
            'greater_than' => $actual > $expected,
            'less_than' => $actual < $expected,
            default => false,
        };
    }
}

Run: docker compose exec app vendor/bin/pest tests/Unit/Routing/ -v Expected: All PASS

  • Step 3: Commit
git add app/Domains/Routing/ tests/Unit/Routing/
git commit -m "feat(routing): smart routing engine with condition evaluator"

Task 9: Analytics Rollups + Pruning

Files:

  • Create: app/Domains/Analytics/Jobs/RollupHourlyJob.php

  • Create: app/Domains/Analytics/Jobs/RollupDailyJob.php

  • Create: app/Domains/Analytics/Jobs/PruneRawClicksJob.php

  • Modify: routes/console.php (schedule)

  • Step 1: RollupHourlyJob

app/Domains/Analytics/Jobs/RollupHourlyJob.php:

<?php

namespace App\Domains\Analytics\Jobs;

use App\Domains\Analytics\Models\Click;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;

class RollupHourlyJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function handle(): void
    {
        $targetHour = now()->startOfHour()->subHour();

        DB::statement("
            INSERT INTO clicks_hourly (link_id, hour, country, device, count, unique_visitors)
            SELECT
                link_id,
                DATE_FORMAT(clicked_at, '%Y-%m-%d %H:00:00') AS hour,
                country,
                device,
                COUNT(*) AS count,
                COUNT(DISTINCT ip_hash) AS unique_visitors
            FROM clicks
            WHERE clicked_at >= ? AND clicked_at < ?
            GROUP BY link_id, hour, country, device
            ON DUPLICATE KEY UPDATE
                count = count + VALUES(count),
                unique_visitors = GREATEST(unique_visitors, VALUES(unique_visitors))
        ", [$targetHour, $targetHour->copy()->addHour()]);
    }
}
  • Step 2: PruneRawClicksJob

app/Domains/Analytics/Jobs/PruneRawClicksJob.php:

<?php

namespace App\Domains\Analytics\Jobs;

use App\Domains\Analytics\Models\Click;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;

class PruneRawClicksJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function handle(): void
    {
        Click::where('clicked_at', '<', now()->subDays(90))
            ->chunkById(1000, function ($clicks) {
                $clicks->toQuery()->delete();
            });
    }
}
  • Step 3: Register schedule in routes/console.php
<?php

use Illuminate\Support\Facades\Schedule;
use App\Domains\Analytics\Jobs\{RollupHourlyJob, RollupDailyJob, PruneRawClicksJob};
use App\Console\Commands\{SyncStripe, ProvisionSsl, ResolveDomains};

Schedule::job(new RollupHourlyJob)->everyFifteenMinutes();
Schedule::job(new RollupDailyJob)->dailyAt('02:00');
Schedule::job(new PruneRawClicksJob)->dailyAt('03:00');
Schedule::command('backup:run')->at('00:00')->at('06:00')->at('12:00')->at('18:00');
  • Step 4: Commit
git add app/Domains/Analytics/Jobs/ routes/console.php
git commit -m "feat(analytics): hourly/daily rollups, 90-day raw prune, schedule"

Task 10: Plan Limits Service

Files:

  • Create: app/Domains/Subscription/Models/Plan.php

  • Create: app/Domains/Subscription/Services/PlanLimitsService.php

  • Create: app/Http/Middleware/EnforceLimits.php

  • Create: tests/Unit/Subscription/PlanLimitsServiceTest.php

  • Step 1: Write failing test

tests/Unit/Subscription/PlanLimitsServiceTest.php:

<?php

use App\Domains\Subscription\Services\PlanLimitsService;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Subscription\Models\Plan;

it('allows link creation when under limit', function () {
    $plan = Plan::factory()->create(['link_limit' => 10]);
    $workspace = Workspace::factory()->create(['plan_id' => $plan->id]);

    $service = new PlanLimitsService;
    expect($service->canCreateLink($workspace))->toBeTrue();
});

it('denies link creation when at limit', function () {
    $plan = Plan::factory()->create(['link_limit' => 2]);
    $workspace = Workspace::factory()->create(['plan_id' => $plan->id]);
    \App\Domains\Link\Models\Link::factory()->count(2)->create(['workspace_id' => $workspace->id]);

    $service = new PlanLimitsService;
    expect($service->canCreateLink($workspace))->toBeFalse();
});

Run: docker compose exec app vendor/bin/pest tests/Unit/Subscription/ -v Expected: FAIL

  • Step 2: Implement PlanLimitsService

app/Domains/Subscription/Services/PlanLimitsService.php:

<?php

namespace App\Domains\Subscription\Services;

use App\Domains\Workspace\Models\Workspace;
use Illuminate\Support\Facades\Redis;

class PlanLimitsService
{
    public function canCreateLink(Workspace $workspace): bool
    {
        $plan = $workspace->plan;
        if (! $plan) {
            return false;
        }
        $count = $workspace->links()->count();
        return $count < $plan->link_limit;
    }

    public function monthlyClicksUsed(Workspace $workspace): int
    {
        $key = "clicks:{$workspace->id}:monthly:" . now()->format('Ym');
        return (int) Redis::get($key);
    }

    public function isClickLimitApproaching(Workspace $workspace): bool
    {
        $plan = $workspace->plan;
        if (! $plan) return false;
        $used = $this->monthlyClicksUsed($workspace);
        return $used >= ($plan->click_limit_monthly * 0.9);
    }

    public function isClickLimitExceeded(Workspace $workspace): bool
    {
        $plan = $workspace->plan;
        if (! $plan) return false;
        return $this->monthlyClicksUsed($workspace) >= $plan->click_limit_monthly;
    }
}

Run: docker compose exec app vendor/bin/pest tests/Unit/Subscription/ -v Expected: All PASS

  • Step 3: Seed the 4 plans

database/seeders/PlanSeeder.php:

<?php

namespace Database\Seeders;

use App\Domains\Subscription\Models\Plan;
use Illuminate\Database\Seeder;

class PlanSeeder extends Seeder
{
    public function run(): void
    {
        $plans = [
            ['name' => 'free',     'monthly_price_cents' => 0,     'workspace_limit' => 1,  'member_limit' => 1,  'custom_domain_limit' => 0, 'link_limit' => 50,    'click_limit_monthly' => 1000],
            ['name' => 'pro',      'monthly_price_cents' => 1900,  'workspace_limit' => 1,  'member_limit' => 3,  'custom_domain_limit' => 1, 'link_limit' => 1000,  'click_limit_monthly' => 25000],
            ['name' => 'business', 'monthly_price_cents' => 7900,  'workspace_limit' => 3,  'member_limit' => 10, 'custom_domain_limit' => 5, 'link_limit' => 10000, 'click_limit_monthly' => 250000],
            ['name' => 'agency',   'monthly_price_cents' => 19900, 'workspace_limit' => 999,'member_limit' => 999,'custom_domain_limit' => 999,'link_limit' => 999999,'click_limit_monthly' => 5000000],
        ];

        foreach ($plans as $plan) {
            Plan::updateOrCreate(['name' => $plan['name']], $plan);
        }
    }
}
docker compose exec app php artisan db:seed --class=PlanSeeder
  • Step 4: Commit
git add app/Domains/Subscription/ database/seeders/PlanSeeder.php tests/Unit/Subscription/
git commit -m "feat(billing): plan limits service, plan seeder (free/pro/business/agency)"

Task 11: QR Code Generator

Files:

  • Create: app/Domains/QrCode/Models/QrCode.php

  • Create: app/Domains/QrCode/Actions/GenerateQrCode.php

  • Create: app/Domains/QrCode/Services/QrRenderer.php

  • Create: tests/Unit/QrCode/QrRendererTest.php

  • Step 1: Write failing test

tests/Unit/QrCode/QrRendererTest.php:

<?php

use App\Domains\QrCode\Services\QrRenderer;

it('generates valid SVG for URL type', function () {
    $renderer = new QrRenderer;
    $svg = $renderer->toSvg('https://example.com', []);

    expect($svg)->toContain('<svg')
        ->toContain('</svg>');
});

it('generates png as base64', function () {
    $renderer = new QrRenderer;
    $png = $renderer->toPngBase64('https://example.com', []);

    expect($png)->toStartWith('data:image/png;base64,');
});

Run: docker compose exec app vendor/bin/pest tests/Unit/QrCode/ -v Expected: FAIL

  • Step 2: Implement QrRenderer

app/Domains/QrCode/Services/QrRenderer.php:

<?php

namespace App\Domains\QrCode\Services;

use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use chillerlan\QRCode\Output\QROutputInterface;

class QrRenderer
{
    public function toSvg(string $data, array $style): string
    {
        $options = new QROptions([
            'outputType' => QRCode::OUTPUT_MARKUP_SVG,
            'imageTransparent' => true,
        ]);

        if (isset($style['foreground'])) {
            $options->markupDark = $style['foreground'];
        }

        return (new QRCode($options))->render($data);
    }

    public function toPngBase64(string $data, array $style): string
    {
        $options = new QROptions([
            'outputType' => QRCode::OUTPUT_IMAGE_PNG,
            'imageTransparent' => false,
            'scale' => 10,
        ]);

        $png = (new QRCode($options))->render($data);
        return 'data:image/png;base64,' . base64_encode($png);
    }

    public function buildPayload(string $type, array $data): string
    {
        return match ($type) {
            'url' => $data['url'],
            'text' => $data['text'],
            'wifi' => "WIFI:T:{$data['encryption']};S:{$data['ssid']};P:{$data['password']};;",
            'vcard' => $this->buildVcard($data),
            default => $data['url'] ?? '',
        };
    }

    private function buildVcard(array $d): string
    {
        return "BEGIN:VCARD\nVERSION:3.0\nFN:{$d['name']}\nEMAIL:{$d['email']}\nTEL:{$d['phone']}\nEND:VCARD";
    }
}

Run: docker compose exec app vendor/bin/pest tests/Unit/QrCode/ -v Expected: All PASS

  • Step 3: Commit
git add app/Domains/QrCode/ tests/Unit/QrCode/
git commit -m "feat(qr): QR code renderer (SVG + PNG), vcard/wifi payload builders"

Task 12: Translation System

Files:

  • Create: app/Domains/Translation/Models/Translation.php

  • Create: app/Domains/Translation/Services/TranslationService.php

  • Create: app/Domains/Translation/Providers/DatabaseTranslationLoader.php

  • Modify: app/Providers/AppServiceProvider.php

  • Step 1: Write failing test

tests/Feature/Translation/TranslationServiceTest.php:

<?php

use App\Domains\Translation\Models\Translation;
use Illuminate\Support\Facades\App;

it('loads translation from database via __()', function () {
    Translation::create([
        'locale' => 'de',
        'namespace' => '*',
        'group' => 'auth',
        'key' => 'login',
        'value' => 'Anmelden',
    ]);

    App::setLocale('de');
    // Force reload
    app('translator')->setLoaded([]);

    expect(__('auth.login'))->toBe('Anmelden');
});
  • Step 2: Implement DatabaseTranslationLoader

app/Domains/Translation/Providers/DatabaseTranslationLoader.php:

<?php

namespace App\Domains\Translation\Providers;

use App\Domains\Translation\Models\Translation;
use Illuminate\Contracts\Translation\Loader;
use Illuminate\Support\Facades\Redis;

class DatabaseTranslationLoader implements Loader
{
    public function load(string $locale, string $group, ?string $namespace = null): array
    {
        $cacheKey = "lang:{$locale}:{$group}";

        $cached = Redis::hGetAll($cacheKey);
        if (! empty($cached)) {
            return $cached;
        }

        $translations = Translation::where('locale', $locale)
            ->where('group', $group)
            ->pluck('value', 'key')
            ->toArray();

        if (! empty($translations)) {
            Redis::hMSet($cacheKey, $translations);
            Redis::expire($cacheKey, 3600);
        }

        return $translations;
    }

    public function addNamespace(string $namespace, string $hint): void {}

    public function addPath(string $path): void {}

    public function namespaces(): array
    {
        return [];
    }

    public function addJsonPath(string $path): void {}
}

Register in AppServiceProvider::boot():

$this->app->singleton('translation.loader', function ($app) {
    return new \App\Domains\Translation\Providers\DatabaseTranslationLoader;
});
  • Step 3: Commit
git add app/Domains/Translation/ tests/Feature/Translation/
git commit -m "feat(i18n): database-backed translation loader with Redis cache"

Task 13: Stripe Billing Integration

Files:

  • Modify: app/Models/User.php (Billable on Workspace, not User)

  • Modify: app/Domains/Workspace/Models/Workspace.php (add Billable)

  • Create: app/Domains/Subscription/Actions/CreateSubscription.php

  • Create: app/Domains/Subscription/Listeners/StripeWebhookListener.php

  • Modify: routes/web.php (Cashier webhook route)

  • Step 1: Add Billable to Workspace

In app/Domains/Workspace/Models/Workspace.php:

use Laravel\Cashier\Billable;

class Workspace extends Model
{
    use SoftDeletes, HasUlids, Billable;
    // ...
}
  • Step 2: Configure Cashier

In .env:

CASHIER_MODEL=App\Domains\Workspace\Models\Workspace
CASHIER_CURRENCY=eur
CASHIER_CURRENCY_LOCALE=de_DE

In config/cashier.php (publish if needed):

docker compose exec app php artisan vendor:publish --tag=cashier-config
  • Step 3: Stripe webhook route

In routes/web.php:

Route::post('/stripe/webhook', '\Laravel\Cashier\Http\Controllers\WebhookController@handleWebhook')
    ->name('cashier.webhook');

Add to CSRF exclusion in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'stripe/*',
    ]);
})
  • Step 4: Write Stripe webhook test

tests/Feature/Billing/StripeWebhookTest.php:

<?php

it('handles subscription_created webhook', function () {
    $workspace = \App\Domains\Workspace\Models\Workspace::factory()->create();

    $payload = [
        'type' => 'customer.subscription.created',
        'data' => [
            'object' => [
                'id' => 'sub_test123',
                'customer' => 'cus_test123',
                'status' => 'active',
                'items' => ['data' => [['price' => ['id' => 'price_pro']]]],
                'current_period_start' => now()->timestamp,
                'current_period_end' => now()->addMonth()->timestamp,
            ]
        ]
    ];

    // This test verifies the webhook endpoint exists and accepts POST
    $response = $this->postJson('/stripe/webhook', $payload, [
        'Stripe-Signature' => 'test',
    ]);

    // 400 because signature invalid — but endpoint exists
    $response->assertStatus(400);
});
  • Step 5: Commit
git add app/Domains/Subscription/ app/Domains/Workspace/Models/Workspace.php routes/web.php tests/Feature/Billing/
git commit -m "feat(billing): Cashier Billable on Workspace, Stripe webhook route"

Task 14: REST API v1

Files:

  • Create: routes/api.php (v1 routes)

  • Create: app/Http/Controllers/Api/V1/LinkController.php

  • Create: app/Http/Controllers/Api/V1/WorkspaceController.php

  • Create: app/Domains/Api/Resources/LinkResource.php

  • Create: tests/Feature/Api/V1/LinkApiTest.php

  • Step 1: Write failing API test

tests/Feature/Api/V1/LinkApiTest.php:

<?php

use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace;
use App\Models\User;

it('lists links for authenticated workspace', function () {
    $user = User::factory()->create();
    $workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
    Link::factory()->count(3)->create(['workspace_id' => $workspace->id]);

    $token = $user->createToken('api-test')->plainTextToken;

    $response = $this->withToken($token)
        ->getJson("/api/v1/workspaces/{$workspace->ulid}/links");

    $response->assertOk()
        ->assertJsonCount(3, 'data');
});

it('creates link via API', function () {
    $user = User::factory()->create();
    $workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
    $token = $user->createToken('api-test')->plainTextToken;

    $response = $this->withToken($token)
        ->postJson("/api/v1/workspaces/{$workspace->ulid}/links", [
            'target_url' => 'https://example.com',
        ]);

    $response->assertCreated()
        ->assertJsonPath('data.target_url', 'https://example.com');
});

Run: docker compose exec app vendor/bin/pest tests/Feature/Api/ -v Expected: FAIL

  • Step 2: Create LinkResource

app/Domains/Api/Resources/LinkResource.php:

<?php

namespace App\Domains\Api\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class LinkResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->ulid,
            'slug' => $this->slug,
            'target_url' => $this->target_url,
            'short_url' => 'https://' . config('SHORT_LINK_DOMAIN', 'nimu.li') . '/' . $this->slug,
            'status' => $this->status,
            'title' => $this->title,
            'tags' => $this->tags ?? [],
            'created_at' => $this->created_at?->toISOString(),
        ];
    }
}
  • Step 3: Create API LinkController

app/Http/Controllers/Api/V1/LinkController.php:

<?php

namespace App\Http\Controllers\Api\V1;

use App\Domains\Api\Resources\LinkResource;
use App\Domains\Link\Actions\CreateLink;
use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class LinkController extends Controller
{
    public function index(Request $request, string $workspaceUlid): AnonymousResourceCollection
    {
        $workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
        $this->authorize('view', $workspace);

        $links = $workspace->links()
            ->latest()
            ->paginate(50);

        return LinkResource::collection($links);
    }

    public function store(Request $request, string $workspaceUlid, CreateLink $action): LinkResource
    {
        $workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
        $this->authorize('update', $workspace);

        $validated = $request->validate([
            'target_url' => 'required|url|max:2048',
            'slug' => 'nullable|string|max:64|alpha_dash',
            'title' => 'nullable|string|max:255',
            'tags' => 'nullable|array',
        ]);

        $link = $action->handle($workspace, $request->user(), $validated);

        return (new LinkResource($link))->response()->setStatusCode(201)->getData(true);
    }

    public function destroy(string $workspaceUlid, string $linkUlid): \Illuminate\Http\Response
    {
        $workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
        $link = Link::where('ulid', $linkUlid)->where('workspace_id', $workspace->id)->firstOrFail();
        $this->authorize('update', $workspace);

        $link->delete();

        return response()->noContent();
    }
}
  • Step 4: Define API routes

routes/api.php:

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\LinkController;
use App\Http\Controllers\Api\V1\WorkspaceController;

Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    Route::prefix('workspaces/{workspace}')->group(function () {
        Route::get('links', [LinkController::class, 'index']);
        Route::post('links', [LinkController::class, 'store']);
        Route::delete('links/{link}', [LinkController::class, 'destroy']);
    });
});

Run: docker compose exec app vendor/bin/pest tests/Feature/Api/ -v Expected: All PASS

  • Step 5: Commit
git add app/Http/Controllers/Api/ app/Domains/Api/ routes/api.php tests/Feature/Api/
git commit -m "feat(api): REST API v1 — links CRUD with Sanctum auth"

Task 15: Outgoing Webhooks

Files:

  • Create: app/Domains/Webhook/Models/Webhook.php

  • Create: app/Domains/Webhook/Services/WebhookDispatcher.php

  • Create: app/Domains/Webhook/Jobs/DeliverWebhookJob.php

  • Create: tests/Feature/Webhook/WebhookDispatchTest.php

  • Step 1: Write failing test

tests/Feature/Webhook/WebhookDispatchTest.php:

<?php

use App\Domains\Webhook\Models\Webhook;
use App\Domains\Webhook\Services\WebhookDispatcher;
use Illuminate\Support\Facades\Http;

it('dispatches webhook with HMAC signature', function () {
    Http::fake(['*' => Http::response('ok', 200)]);

    $webhook = Webhook::factory()->create([
        'url' => 'https://example.com/hook',
        'secret' => 'mysecret',
        'events' => ['click.recorded'],
        'is_active' => true,
    ]);

    $dispatcher = new WebhookDispatcher;
    $dispatcher->dispatch('click.recorded', ['link_id' => 1], $webhook->workspace_id);

    Http::assertSent(function ($request) {
        return $request->hasHeader('X-Nimuli-Signature')
            && str_starts_with($request->header('X-Nimuli-Signature')[0], 'sha256=');
    });
});
  • Step 2: Implement WebhookDispatcher

app/Domains/Webhook/Services/WebhookDispatcher.php:

<?php

namespace App\Domains\Webhook\Services;

use App\Domains\Webhook\Jobs\DeliverWebhookJob;
use App\Domains\Webhook\Models\Webhook;

class WebhookDispatcher
{
    public function dispatch(string $event, array $payload, int $workspaceId): void
    {
        Webhook::where('workspace_id', $workspaceId)
            ->where('is_active', true)
            ->whereJsonContains('events', $event)
            ->each(function (Webhook $webhook) use ($event, $payload) {
                DeliverWebhookJob::dispatch($webhook, $event, $payload)
                    ->onQueue('default');
            });
    }
}

app/Domains/Webhook/Jobs/DeliverWebhookJob.php:

<?php

namespace App\Domains\Webhook\Jobs;

use App\Domains\Webhook\Models\Webhook;
use App\Domains\Webhook\Models\WebhookDelivery;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Http;

class DeliverWebhookJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries = 5;
    public int $backoff = 60;

    public function __construct(
        public readonly Webhook $webhook,
        public readonly string $event,
        public readonly array $payload,
    ) {}

    public function handle(): void
    {
        $body = json_encode([
            'event' => $this->event,
            'payload' => $this->payload,
            'timestamp' => now()->toISOString(),
        ]);

        $signature = 'sha256=' . hash_hmac('sha256', $body, $this->webhook->secret);

        // SSRF protection — block private IP ranges
        $host = parse_url($this->webhook->url, PHP_URL_HOST);
        if ($this->isPrivateHost($host)) {
            return;
        }

        $response = Http::timeout(10)
            ->withHeaders([
                'Content-Type' => 'application/json',
                'X-Nimuli-Signature' => $signature,
                'X-Nimuli-Event' => $this->event,
            ])
            ->post($this->webhook->url, json_decode($body, true));

        WebhookDelivery::create([
            'webhook_id' => $this->webhook->id,
            'event' => $this->event,
            'payload' => $body,
            'status' => $response->successful() ? 'success' : 'failed',
            'response_code' => $response->status(),
            'attempts' => $this->attempts(),
        ]);
    }

    private function isPrivateHost(?string $host): bool
    {
        if (! $host) return true;
        $ip = gethostbyname($host);
        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
    }
}

Run: docker compose exec app vendor/bin/pest tests/Feature/Webhook/ -v Expected: All PASS

  • Step 3: Commit
git add app/Domains/Webhook/ tests/Feature/Webhook/
git commit -m "feat(webhooks): outgoing webhooks with HMAC signature, SSRF protection, retry"

Task 16: Security Headers + GDPR Middleware

Files:

  • Create: app/Http/Middleware/SecurityHeaders.php

  • Modify: bootstrap/app.php

  • Create: tests/Feature/Security/SecurityHeadersTest.php

  • Step 1: Write failing security test

tests/Feature/Security/SecurityHeadersTest.php:

<?php

it('sends HSTS header on all requests', function () {
    $response = $this->get('/');
    $response->assertHeader('Strict-Transport-Security');
    $response->assertHeader('X-Content-Type-Options', 'nosniff');
    $response->assertHeader('X-Frame-Options', 'DENY');
    $response->assertHeader('Referrer-Policy', 'strict-origin');
});

Run: docker compose exec app vendor/bin/pest tests/Feature/Security/ -v Expected: FAIL

  • Step 2: Implement SecurityHeaders middleware

app/Http/Middleware/SecurityHeaders.php:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class SecurityHeaders
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('X-Frame-Options', 'DENY');
        $response->headers->set('Referrer-Policy', 'strict-origin');
        $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');

        return $response;
    }
}

Register in bootstrap/app.php web middleware stack.

Run: docker compose exec app vendor/bin/pest tests/Feature/Security/ -v Expected: PASS

  • Step 3: Commit
git add app/Http/Middleware/SecurityHeaders.php bootstrap/app.php tests/Feature/Security/
git commit -m "feat(security): HSTS, X-Frame-Options, CSP, Referrer-Policy headers"

Task 17: Livewire App Layout + Sidebar

Files:

  • Create: app/Livewire/Layouts/AppLayout.php

  • Create: resources/views/layouts/app.blade.php

  • Create: resources/views/components/sidebar.blade.php

  • Create: resources/views/components/topbar.blade.php

  • Step 1: App layout component

resources/views/layouts/app.blade.php:

<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" data-theme="{{ auth()->user()?->theme ?? 'dark' }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $title ?? 'Nimuli' }}</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @livewireStyles
</head>
<body class="bg-(--color-bg) text-(--color-t1) font-sans antialiased">
    <div class="flex h-screen overflow-hidden">
        <x-sidebar />
        <div class="flex-1 flex flex-col overflow-hidden">
            <x-topbar />
            <main class="flex-1 overflow-y-auto p-6">
                {{ $slot }}
            </main>
        </div>
    </div>
    @livewireScripts
    <livewire:modal />
</body>
</html>
  • Step 2: Sidebar component

resources/views/components/sidebar.blade.php:

@php
    $workspace = app('current_workspace');
    $nav = [
        ['label' => 'Dashboard', 'route' => 'dashboard', 'icon' => 'grid'],
        ['label' => 'Links', 'route' => 'links.index', 'icon' => 'link'],
        ['label' => 'QR Codes', 'route' => 'qr.index', 'icon' => 'qrcode'],
        ['label' => 'Link-in-Bio', 'route' => 'bio.index', 'icon' => 'layout'],
        ['label' => 'Analytics', 'route' => 'analytics.index', 'icon' => 'chart'],
        ['label' => 'Domains', 'route' => 'domains.index', 'icon' => 'globe'],
        ['label' => 'Settings', 'route' => 'settings.index', 'icon' => 'settings'],
    ];
@endphp
<aside class="w-60 flex-shrink-0 bg-(--color-s1) border-r border-(--color-b1) flex flex-col">
    <!-- Logo -->
    <div class="h-16 flex items-center px-5 border-b border-(--color-b1)">
        <span class="text-lg font-bold text-(--color-t1) tracking-tight">Nimuli</span>
    </div>

    <!-- Workspace box -->
    @if($workspace)
    <div class="px-3 py-3 border-b border-(--color-b1)">
        <div class="text-xs text-(--color-t2) uppercase tracking-wider mb-1">Workspace</div>
        <div class="text-sm font-medium text-(--color-t1) truncate">{{ $workspace->name }}</div>
    </div>
    @endif

    <!-- Nav -->
    <nav class="flex-1 px-2 py-3 space-y-0.5">
        @foreach($nav as $item)
        <a href="{{ route($item['route'], $workspace?->ulid ?? '') }}"
           class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors
                  {{ request()->routeIs($item['route'] . '*') ? 'bg-(--color-b2) text-(--color-t1)' : 'text-(--color-t2) hover:text-(--color-t1) hover:bg-(--color-b1)' }}">
            <span class="w-4 h-4">@svg($item['icon'])</span>
            {{ $item['label'] }}
        </a>
        @endforeach
    </nav>

    <!-- Plan box -->
    @if($workspace?->plan)
    <div class="px-3 py-3 border-t border-(--color-b1)">
        <div class="text-xs text-(--color-t2)">Plan: {{ ucfirst($workspace->plan->name) }}</div>
    </div>
    @endif
</aside>
  • Step 3: Commit
git add resources/views/layouts/ resources/views/components/ app/Livewire/Layouts/
git commit -m "feat(ui): app layout with sidebar, topbar, dual-theme"

Files:

  • Create: app/Livewire/Pages/Links/Index.php

  • Create: resources/views/livewire/pages/links/index.blade.php

  • Create: app/Livewire/Pages/Links/CreateModal.php

  • Step 1: Links index component

app/Livewire/Pages/Links/Index.php:

<?php

namespace App\Livewire\Pages\Links;

use App\Domains\Link\Models\Link;
use App\Domains\Link\Actions\CreateLink;
use Livewire\Component;
use Livewire\WithPagination;

class Index extends Component
{
    use WithPagination;

    public string $search = '';
    public string $statusFilter = '';

    public function render()
    {
        $workspace = app('current_workspace');

        $links = Link::where('workspace_id', $workspace->id)
            ->when($this->search, fn($q) => $q->where(function($q) {
                $q->where('title', 'like', "%{$this->search}%")
                  ->orWhere('slug', 'like', "%{$this->search}%")
                  ->orWhere('target_url', 'like', "%{$this->search}%");
            }))
            ->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
            ->latest()
            ->paginate(20);

        return view('livewire.pages.links.index', compact('links'))
            ->layout('layouts.app', ['title' => 'Links']);
    }

    public function deleteLink(int $id): void
    {
        $workspace = app('current_workspace');
        $link = Link::where('id', $id)->where('workspace_id', $workspace->id)->firstOrFail();
        $this->authorize('update', $workspace);
        $link->delete();
        $this->resetPage();
    }
}

resources/views/livewire/pages/links/index.blade.php:

<div>
    <div class="flex items-center justify-between mb-6">
        <h1 class="text-2xl font-bold text-(--color-t1)">Links</h1>
        <button
            x-on:click="$dispatch('openModal', {component: 'pages.links.create-modal'})"
            class="px-4 py-2 bg-(--color-blue) text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
            + New Link
        </button>
    </div>

    <!-- Search + Filter -->
    <div class="flex gap-3 mb-4">
        <input wire:model.live.debounce.300ms="search"
               type="text" placeholder="Search links..."
               class="flex-1 px-3 py-2 rounded-lg bg-(--color-s2) border border-(--color-b1) text-(--color-t1) text-sm focus:outline-none focus:border-(--color-blue)" />
        <select wire:model.live="statusFilter"
                class="px-3 py-2 rounded-lg bg-(--color-s2) border border-(--color-b1) text-(--color-t1) text-sm focus:outline-none">
            <option value="">All status</option>
            <option value="active">Active</option>
            <option value="disabled">Disabled</option>
            <option value="expired">Expired</option>
        </select>
    </div>

    <!-- Links table -->
    <div class="bg-(--color-s2) rounded-xl border border-(--color-b1) overflow-hidden">
        <table class="w-full text-sm">
            <thead class="border-b border-(--color-b1)">
                <tr class="text-(--color-t2) text-xs uppercase tracking-wider">
                    <th class="px-4 py-3 text-left">Link</th>
                    <th class="px-4 py-3 text-left">Target</th>
                    <th class="px-4 py-3 text-right">Clicks</th>
                    <th class="px-4 py-3 text-center">Status</th>
                    <th class="px-4 py-3"></th>
                </tr>
            </thead>
            <tbody class="divide-y divide-(--color-b1)">
                @forelse($links as $link)
                <tr class="hover:bg-(--color-b1) transition-colors">
                    <td class="px-4 py-3">
                        <div class="font-mono text-(--color-blue) text-sm">{{ config('SHORT_LINK_DOMAIN') }}/{{ $link->slug }}</div>
                        @if($link->title)
                        <div class="text-(--color-t2) text-xs mt-0.5">{{ $link->title }}</div>
                        @endif
                    </td>
                    <td class="px-4 py-3 text-(--color-t2) truncate max-w-xs">{{ $link->target_url }}</td>
                    <td class="px-4 py-3 text-right font-mono text-(--color-t1)">0</td>
                    <td class="px-4 py-3 text-center">
                        <span class="px-2 py-0.5 rounded-full text-xs font-medium
                            {{ $link->status === 'active' ? 'bg-green-500/10 text-(--color-green)' : 'bg-(--color-b2) text-(--color-t2)' }}">
                            {{ $link->status }}
                        </span>
                    </td>
                    <td class="px-4 py-3 text-right">
                        <button wire:click="deleteLink({{ $link->id }})"
                                wire:confirm="Delete this link?"
                                class="text-(--color-red) text-xs hover:opacity-80">Delete</button>
                    </td>
                </tr>
                @empty
                <tr>
                    <td colspan="5" class="px-4 py-12 text-center text-(--color-t2)">No links yet. Create your first link.</td>
                </tr>
                @endforelse
            </tbody>
        </table>
    </div>

    <div class="mt-4">{{ $links->links() }}</div>
</div>
  • Step 2: Register routes for workspace pages

In routes/web.php, add workspace-scoped routes:

Route::middleware(['auth', 'verified'])->prefix('w/{workspace}')->name('w.')->group(function () {
    Route::get('/dashboard', \App\Livewire\Pages\Dashboard::class)->name('dashboard');
    Route::get('/links', \App\Livewire\Pages\Links\Index::class)->name('links.index');
    Route::get('/qr', \App\Livewire\Pages\QrCodes\Index::class)->name('qr.index');
    Route::get('/bio', \App\Livewire\Pages\Bio\Index::class)->name('bio.index');
    Route::get('/analytics', \App\Livewire\Pages\Analytics\Index::class)->name('analytics.index');
    Route::get('/domains', \App\Livewire\Pages\Domains\Index::class)->name('domains.index');
    Route::get('/settings', \App\Livewire\Pages\Settings\Index::class)->name('settings.index');
    Route::get('/billing', \App\Livewire\Pages\Billing\Index::class)->name('billing.index');
});
  • Step 3: Commit
git add app/Livewire/Pages/Links/ resources/views/livewire/pages/links/ routes/web.php
git commit -m "feat(ui): links index page with search, filter, delete"

Task 19: Analytics Dashboard (Live)

Files:

  • Create: app/Livewire/Pages/Analytics/Index.php

  • Create: resources/views/livewire/pages/analytics/index.blade.php

  • Create: app/Domains/Analytics/Events/ClickRecorded.php

  • Step 1: ClickRecorded event for Reverb

app/Domains/Analytics/Events/ClickRecorded.php:

<?php

namespace App\Domains\Analytics\Events;

use App\Domains\Analytics\Models\Click;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;

class ClickRecorded implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets;

    public function __construct(public readonly Click $click) {}

    public function broadcastOn(): Channel
    {
        return new Channel("workspace.{$this->click->workspace_id}.analytics");
    }

    public function broadcastAs(): string
    {
        return 'click.recorded';
    }

    public function broadcastWith(): array
    {
        return [
            'link_id' => $this->click->link_id,
            'country' => $this->click->country,
            'device' => $this->click->device,
            'clicked_at' => $this->click->clicked_at?->toISOString(),
        ];
    }
}

Fire in RecordClickJob::handle() after Click::create():

event(new \App\Domains\Analytics\Events\ClickRecorded($click));
  • Step 2: Analytics Livewire page

app/Livewire/Pages/Analytics/Index.php:

<?php

namespace App\Livewire\Pages\Analytics;

use App\Domains\Analytics\Models\ClickDaily;
use Livewire\Component;
use Livewire\Attributes\On;

class Index extends Component
{
    public array $recentClicks = [];
    public int $totalToday = 0;
    public int $totalThisMonth = 0;

    public function mount(): void
    {
        $workspace = app('current_workspace');
        $this->totalToday = $workspace->links()
            ->withCount(['clicks as today_count' => fn($q) => $q->whereDate('clicked_at', today())])
            ->get()
            ->sum('today_count');
    }

    #[On('echo:workspace.{current_workspace.id}.analytics,click.recorded')]
    public function handleNewClick(array $data): void
    {
        array_unshift($this->recentClicks, $data);
        $this->recentClicks = array_slice($this->recentClicks, 0, 10);
        $this->totalToday++;
    }

    public function render()
    {
        return view('livewire.pages.analytics.index')
            ->layout('layouts.app', ['title' => 'Analytics']);
    }
}
  • Step 3: Commit
git add app/Domains/Analytics/Events/ app/Livewire/Pages/Analytics/ resources/views/livewire/pages/analytics/
git commit -m "feat(analytics): live dashboard with Reverb WebSocket, click event broadcasting"

Task 20: AI Suite

Files:

  • Create: app/Domains/Ai/Services/AiService.php

  • Create: app/Domains/Ai/Actions/GenerateSlug.php

  • Create: app/Domains/Ai/Actions/GenerateAbVariants.php

  • Create: tests/Unit/Ai/GenerateSlugTest.php

  • Step 1: Write failing test

tests/Unit/Ai/GenerateSlugTest.php:

<?php

use App\Domains\Ai\Actions\GenerateSlug;
use App\Domains\Ai\Services\AiService;
use Mockery;

it('generates slug from URL via AI service', function () {
    $mockAi = Mockery::mock(AiService::class);
    $mockAi->shouldReceive('complete')
        ->once()
        ->andReturn('best-promo');

    $action = new GenerateSlug($mockAi);
    $slug = $action->handle('https://example.com/summer-sale-2026');

    expect($slug)->toBe('best-promo');
});
  • Step 2: Implement AiService (Anthropic + OpenAI adapter)

app/Domains/Ai/Services/AiService.php:

<?php

namespace App\Domains\Ai\Services;

use Illuminate\Support\Facades\Http;

class AiService
{
    public function complete(string $prompt, string $model = 'claude-sonnet-4-6'): string
    {
        $apiKey = config('services.anthropic.api_key');

        if ($apiKey) {
            $response = Http::withHeaders([
                'x-api-key' => $apiKey,
                'anthropic-version' => '2023-06-01',
                'content-type' => 'application/json',
            ])->post('https://api.anthropic.com/v1/messages', [
                'model' => $model,
                'max_tokens' => 256,
                'messages' => [['role' => 'user', 'content' => $prompt]],
            ]);

            return trim($response->json('content.0.text') ?? '');
        }

        return '';
    }
}

app/Domains/Ai/Actions/GenerateSlug.php:

<?php

namespace App\Domains\Ai\Actions;

use App\Domains\Ai\Services\AiService;

class GenerateSlug
{
    public function __construct(private AiService $ai) {}

    public function handle(string $targetUrl): string
    {
        $prompt = "Generate a short, memorable URL slug (3-8 chars, lowercase, hyphens only) for this URL: {$targetUrl}. Reply with ONLY the slug, no explanation.";

        $slug = $this->ai->complete($prompt);

        // Sanitize
        return preg_replace('/[^a-z0-9-]/', '', strtolower($slug));
    }
}

Run: docker compose exec app vendor/bin/pest tests/Unit/Ai/ -v Expected: PASS

  • Step 3: Add Anthropic config

config/services.php, add:

'anthropic' => [
    'api_key' => env('ANTHROPIC_API_KEY'),
],
  • Step 4: Commit
git add app/Domains/Ai/ config/services.php tests/Unit/Ai/
git commit -m "feat(ai): AiService (Anthropic adapter), GenerateSlug action"

Task 21: 2FA (TOTP)

Files:

  • Modify: app/Models/User.php (2FA methods)

  • Create: app/Livewire/Pages/Settings/TwoFactor.php

  • Create: resources/views/livewire/pages/settings/two-factor.blade.php

  • Step 1: Publish 2FA config

docker compose exec app php artisan vendor:publish --provider="PragmaRX\Google2FALaravel\ServiceProvider"
  • Step 2: Add 2FA methods to User model

In app/Models/User.php:

use PragmaRX\Google2FA\Google2FA;

public function enableTwoFactor(string $secret): array
{
    $recoveryCodes = collect(range(1, 8))->map(fn() => implode('-', [
        strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
        strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
    ]))->all();

    $this->update([
        'two_factor_secret' => encrypt($secret),
        'two_factor_recovery' => encrypt(json_encode($recoveryCodes)),
    ]);

    return $recoveryCodes;
}

public function disableTwoFactor(): void
{
    $this->update([
        'two_factor_secret' => null,
        'two_factor_recovery' => null,
        'two_factor_confirmed_at' => null,
    ]);
}

public function hasTwoFactorEnabled(): bool
{
    return $this->two_factor_confirmed_at !== null;
}
  • Step 3: Write test for 2FA enable

tests/Feature/Settings/TwoFactorTest.php:

<?php

use App\Models\User;

it('enables 2FA and returns recovery codes', function () {
    $user = User::factory()->create();

    $codes = $user->enableTwoFactor('JBSWY3DPEHPK3PXP');

    expect($codes)->toHaveCount(8)
        ->and($user->fresh()->two_factor_secret)->not->toBeNull();
});

Run: docker compose exec app vendor/bin/pest tests/Feature/Settings/ -v Expected: PASS

  • Step 4: Commit
git add app/Models/User.php app/Livewire/Pages/Settings/ tests/Feature/Settings/
git commit -m "feat(auth): TOTP 2FA enable/disable with recovery codes"

Files:

  • Create: app/Domains/Bio/Models/BioPage.php

  • Create: app/Domains/Bio/Models/BioBlock.php

  • Create: app/Domains/Bio/Actions/CreateBioPage.php

  • Create: app/Http/Controllers/BioPageController.php (public render)

  • Step 1: BioPage model with Spatie Translatable

app/Domains/Bio/Models/BioPage.php:

<?php

namespace App\Domains\Bio\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Spatie\Translatable\HasTranslations;

class BioPage extends Model
{
    use SoftDeletes, HasUlids, HasTranslations;

    protected $guarded = ['id', 'workspace_id'];
    public $translatable = ['title', 'description'];

    protected $casts = ['theme' => 'array', 'is_published' => 'boolean'];

    public function blocks()
    {
        return $this->hasMany(BioBlock::class)->orderBy('position');
    }
}
  • Step 2: Public bio page controller

app/Http/Controllers/BioPageController.php:

<?php

namespace App\Http\Controllers;

use App\Domains\Bio\Models\BioPage;
use App\Domains\Domain\Models\Domain;

class BioPageController extends Controller
{
    public function show(string $slug)
    {
        $host = request()->getHost();
        $domain = Domain::where('hostname', $host)->first();

        $page = BioPage::where('slug', $slug)
            ->where('domain_id', $domain?->id)
            ->where('is_published', true)
            ->with('blocks')
            ->firstOrFail();

        return view('bio.show', compact('page'));
    }
}

Add route in web.php for custom bio domains (after main routes):

Route::get('/bio/{slug}', [\App\Http\Controllers\BioPageController::class, 'show'])->name('bio.public');
  • Step 3: Commit
git add app/Domains/Bio/ app/Http/Controllers/BioPageController.php
git commit -m "feat(bio): Link-in-Bio pages with translatable fields, public render"

Task 23: Domain Management

Files:

  • Create: app/Domains/Domain/Models/Domain.php

  • Create: app/Domains/Domain/Actions/CreateDomain.php

  • Create: app/Domains/Domain/Actions/VerifyDomain.php

  • Create: app/Domains/Domain/Jobs/VerifyPendingDomainsJob.php

  • Step 1: Domain model

app/Domains/Domain/Models/Domain.php:

<?php

namespace App\Domains\Domain\Models;

use Illuminate\Database\Eloquent\Model;

class Domain extends Model
{
    protected $guarded = ['id', 'workspace_id'];

    protected $casts = [
        'verified_at' => 'datetime',
        'ssl_issued_at' => 'datetime',
        'ssl_expires_at' => 'datetime',
        'is_default' => 'boolean',
    ];

    public function workspace()
    {
        return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
    }

    public function isVerified(): bool
    {
        return $this->verified_at !== null;
    }
}
  • Step 2: VerifyDomain action (DNS TXT check)

app/Domains/Domain/Actions/VerifyDomain.php:

<?php

namespace App\Domains\Domain\Actions;

use App\Domains\Domain\Models\Domain;

class VerifyDomain
{
    public function handle(Domain $domain): bool
    {
        $records = dns_get_record("_nimuli.{$domain->hostname}", DNS_TXT);

        foreach ($records as $record) {
            if (($record['txt'] ?? '') === "nimuli-verify={$domain->verification_token}") {
                $domain->update([
                    'verified_at' => now(),
                    'ssl_status' => 'pending',
                ]);
                return true;
            }
        }

        return false;
    }
}
  • Step 3: Write test for domain verification

tests/Feature/Domain/VerifyDomainTest.php:

<?php

use App\Domains\Domain\Actions\VerifyDomain;
use App\Domains\Domain\Models\Domain;

it('marks domain as verified when TXT record matches', function () {
    $domain = Domain::factory()->create([
        'hostname' => 'example.com',
        'verification_token' => 'abc123',
    ]);

    // Mock DNS lookup
    $action = Mockery::mock(VerifyDomain::class)->makePartial();
    // For unit test, just verify the action handles a true DNS response

    expect($domain->isVerified())->toBeFalse();
});
  • Step 4: Commit
git add app/Domains/Domain/ tests/Feature/Domain/
git commit -m "feat(domains): Domain model, DNS TXT verification action"

Task 24: Full Test Suite + PHPStan

  • Step 1: Run full test suite
docker compose exec app vendor/bin/pest --parallel --no-coverage

Expected: All tests PASS. Fix any failures before proceeding.

  • Step 2: Set up PHPStan level 6

Create phpstan.neon:

includes:
    - vendor/larastan/larastan/extension.neon

parameters:
    paths:
        - app
    level: 6
    checkMissingIterableValueType: false

Run: docker compose exec app vendor/bin/phpstan analyse Expected: No errors (or fix errors before continuing)

  • Step 3: Run Pint code style
docker compose exec app vendor/bin/pint

Expected: All files formatted.

  • Step 4: Final commit before UI polish
git add .
git commit -m "fix(phpstan): resolve level 6 type errors"

Task 25: Phase 1 Launch Checklist

  • Stripe Live mode keys in production .env
  • ANTHROPIC_API_KEY or OPENAI_API_KEY set
  • Run php artisan optimize in production
  • Sentry DSN configured (SENTRY_LARAVEL_DSN)
  • Backup destination configured in config/backup.php
  • DSGVO: Cookie banner enabled in workspace settings
  • Run php artisan db:seed --class=PlanSeeder in production
  • Create admin user via tinker
  • Test Stripe webhooks with CLI: stripe listen --forward-to ...
  • Verify all 5 domains resolve through NPM: app.nimuli.com, nimu.li, ws.nimuli.com, vite.nimuli.com, mail.intern.nimuli.com
  • Smoke test redirect: create link, click, verify analytics
  • Run vendor/bin/pest --parallel — all green
  • Run vendor/bin/phpstan analyse — no errors
  • git push origin main triggers CI pipeline

Remaining Livewire Pages (implement after Tasks 1-24)

Each page follows the same pattern as Task 18 (Links). Create the Livewire class + Blade view:

Page Component Class Route Name
Dashboard App\Livewire\Pages\Dashboard w.dashboard
QR Code Index App\Livewire\Pages\QrCodes\Index w.qr.index
Bio Pages Index App\Livewire\Pages\Bio\Index w.bio.index
Analytics App\Livewire\Pages\Analytics\Index w.analytics.index
Domains App\Livewire\Pages\Domains\Index w.domains.index
Workspace Settings App\Livewire\Pages\Settings\Index w.settings.index
Billing App\Livewire\Pages\Billing\Index w.billing.index
Workspace Members App\Livewire\Pages\Workspace\Members w.workspace.members

For each: write Pest test first → implement component → implement view → commit.