Building SaaS with Laravel (Part 7): REST API & Webhooks

· 18 min read

In Part 6, we built the admin dashboard. Now it's time for something every serious SaaS needs: an API for third-party integrations and webhooks to notify external systems.

1. API Architecture

┌──────────────────────────────────────────┐
│              API Gateway                  │
│                                           │
│  Request → Auth → Rate Limit → Tenant     │
│              ↓                             │
│     ┌────────────────────────┐            │
│     │     API Controller      │            │
│     │  ┌──────┐  ┌──────┐   │            │
│     │  │ v1   │  │ v2   │   │            │
│     │  └──────┘  └──────┘   │            │
│     └────────────────────────┘            │
│              ↓                             │
│     API Resources → JSON Response          │
└──────────────────────────────────────────┘

2. API Authentication with Sanctum

Configuring Token Abilities

// app/Domain/Api/Enums/TokenAbility.php
<?php

declare(strict_types=1);

namespace App\Domain\Api\Enums;

enum TokenAbility: string
{
    case ProjectsRead = 'projects:read';
    case ProjectsWrite = 'projects:write';
    case TasksRead = 'tasks:read';
    case TasksWrite = 'tasks:write';
    case TeamsRead = 'teams:read';
    case MembersRead = 'members:read';
    case WebhooksManage = 'webhooks:manage';

    public static function all(): array
    {
        return array_column(self::cases(), 'value');
    }

    public static function readOnly(): array
    {
        return [
            self::ProjectsRead->value,
            self::TasksRead->value,
            self::TeamsRead->value,
            self::MembersRead->value,
        ];
    }
}

API Token Controller

// app/Http/Controllers/Api/ApiTokenController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Domain\Api\Enums\TokenAbility;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class ApiTokenController extends Controller
{
    public function index(Request $request)
    {
        $tokens = $request->user()->tokens()
            ->orderByDesc('last_used_at')
            ->get();

        return view('settings.api-tokens', [
            'tokens' => $tokens,
            'abilities' => TokenAbility::cases(),
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'abilities' => 'required|array|min:1',
            'abilities.*' => 'string|in:' . implode(',', TokenAbility::all()),
        ]);

        // Check if plan allows API access
        $tenant = current_tenant();
        if (! $tenant->hasFeature('api_access')) {
            return back()->withErrors([
                'api' => 'Your current plan does not support API access. Please upgrade to Business.',
            ]);
        }

        $token = $request->user()->createToken(
            $validated['name'],
            $validated['abilities'],
        );

        return back()->with('token', $token->plainTextToken);
    }

    public function destroy(Request $request, string $tokenId)
    {
        $request->user()->tokens()->where('id', $tokenId)->delete();

        return back()->with('success', 'API token has been revoked.');
    }
}

3. API Routes with Versioning

// routes/api.php
<?php

use App\Http\Controllers\Api\V1;
use App\Http\Middleware\CheckFeatureLimit;
use App\Http\Middleware\ResolveApiTenant;
use Illuminate\Support\Facades\Route;

// API v1
Route::prefix('v1')
    ->middleware(['auth:sanctum', 'resolve.api.tenant', 'throttle:api'])
    ->name('api.v1.')
    ->group(function () {
        // Projects
        Route::apiResource('projects', V1\ProjectController::class);

        // Tasks (nested under projects)
        Route::apiResource('projects.tasks', V1\TaskController::class)->shallow();

        // Teams
        Route::get('/teams', [V1\TeamController::class, 'index'])->name('teams.index');
        Route::get('/teams/{team}', [V1\TeamController::class, 'show'])->name('teams.show');

        // Members
        Route::get('/members', [V1\MemberController::class, 'index'])->name('members.index');

        // Webhooks
        Route::apiResource('webhooks', V1\WebhookController::class)
            ->middleware('ability:webhooks:manage');

        // Current user / tenant
        Route::get('/me', [V1\ProfileController::class, 'show'])->name('me');
    });

4. API Tenant Resolution

// app/Http/Middleware/ResolveApiTenant.php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Providers\TenantServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ResolveApiTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if (! $user) {
            return response()->json(['error' => 'Unauthenticated'], 401);
        }

        $tenant = $user->currentTenant;

        // Allow specifying tenant via header
        $tenantSlug = $request->header('X-Tenant');
        if ($tenantSlug) {
            $tenant = $user->tenants()
                ->where('slug', $tenantSlug)
                ->first();

            if (! $tenant) {
                return response()->json([
                    'error' => 'invalid_tenant',
                    'message' => 'You do not have access to this organization.',
                ], 403);
            }
        }

        if (! $tenant) {
            return response()->json([
                'error' => 'no_tenant',
                'message' => 'No organization selected. Set X-Tenant header.',
            ], 400);
        }

        TenantServiceProvider::setTenant($tenant);

        return $next($request);
    }
}

5. API Controllers

Project API Controller

// app/Http/Controllers/Api/V1/ProjectController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1;

use App\Domain\Project\Actions\CreateProjectAction;
use App\Domain\Project\Models\Project;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\ProjectResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class ProjectController extends Controller
{
    /**
     * GET /api/v1/projects
     */
    public function index(Request $request): AnonymousResourceCollection
    {
        $request->user()->tokenCan('projects:read') || abort(403);

        $projects = Project::query()
            ->when($request->input('status'), fn ($q, $status) => $q->where('status', $status))
            ->when($request->input('team_id'), fn ($q, $teamId) => $q->where('team_id', $teamId))
            ->when($request->input('search'), fn ($q, $search) =>
                $q->where('name', 'like', "%{$search}%")
            )
            ->withCount('tasks')
            ->orderByDesc('updated_at')
            ->paginate($request->input('per_page', 25));

        return ProjectResource::collection($projects);
    }

    /**
     * POST /api/v1/projects
     */
    public function store(Request $request, CreateProjectAction $action): JsonResponse
    {
        $request->user()->tokenCan('projects:write') || abort(403);

        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'description' => 'nullable|string|max:1000',
            'team_id' => 'nullable|exists:teams,id',
            'due_date' => 'nullable|date|after:today',
        ]);

        $project = $action->execute($validated);

        return (new ProjectResource($project))
            ->response()
            ->setStatusCode(201);
    }

    /**
     * GET /api/v1/projects/{project}
     */
    public function show(Request $request, Project $project): ProjectResource
    {
        $request->user()->tokenCan('projects:read') || abort(403);

        $project->load(['tasks', 'team']);

        return new ProjectResource($project);
    }

    /**
     * PUT /api/v1/projects/{project}
     */
    public function update(Request $request, Project $project): ProjectResource
    {
        $request->user()->tokenCan('projects:write') || abort(403);

        $validated = $request->validate([
            'name' => 'sometimes|string|max:255',
            'description' => 'nullable|string|max:1000',
            'status' => 'sometimes|in:active,archived,completed',
            'due_date' => 'nullable|date',
        ]);

        $project->update($validated);

        return new ProjectResource($project);
    }

    /**
     * DELETE /api/v1/projects/{project}
     */
    public function destroy(Request $request, Project $project): JsonResponse
    {
        $request->user()->tokenCan('projects:write') || abort(403);

        $project->delete();

        return response()->json(null, 204);
    }
}

Task API Controller

// app/Http/Controllers/Api/V1/TaskController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1;

use App\Domain\Project\Models\Project;
use App\Domain\Project\Models\Task;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\TaskResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class TaskController extends Controller
{
    public function index(Request $request, Project $project): AnonymousResourceCollection
    {
        $request->user()->tokenCan('tasks:read') || abort(403);

        $tasks = $project->tasks()
            ->when($request->input('status'), fn ($q, $s) => $q->where('status', $s))
            ->when($request->input('assigned_to'), fn ($q, $id) => $q->where('assigned_to', $id))
            ->when($request->input('priority'), fn ($q, $p) => $q->where('priority', $p))
            ->with(['assignee:id,name,email', 'creator:id,name'])
            ->orderBy('position')
            ->paginate($request->input('per_page', 50));

        return TaskResource::collection($tasks);
    }

    public function store(Request $request, Project $project): JsonResponse
    {
        $request->user()->tokenCan('tasks:write') || abort(403);

        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string|max:5000',
            'status' => 'sometimes|in:todo,in_progress,review,done',
            'priority' => 'sometimes|in:low,medium,high,urgent',
            'assigned_to' => 'nullable|exists:users,id',
            'due_date' => 'nullable|date',
        ]);

        $task = $project->tasks()->create([
            ...$validated,
            'tenant_id' => current_tenant()->id,
            'created_by' => $request->user()->id,
        ]);

        return (new TaskResource($task))
            ->response()
            ->setStatusCode(201);
    }

    public function show(Request $request, Task $task): TaskResource
    {
        $request->user()->tokenCan('tasks:read') || abort(403);

        $task->load(['assignee', 'creator', 'project']);

        return new TaskResource($task);
    }

    public function update(Request $request, Task $task): TaskResource
    {
        $request->user()->tokenCan('tasks:write') || abort(403);

        $validated = $request->validate([
            'title' => 'sometimes|string|max:255',
            'description' => 'nullable|string|max:5000',
            'status' => 'sometimes|in:todo,in_progress,review,done',
            'priority' => 'sometimes|in:low,medium,high,urgent',
            'assigned_to' => 'nullable|exists:users,id',
            'due_date' => 'nullable|date',
            'position' => 'sometimes|integer|min:0',
        ]);

        $task->update($validated);

        return new TaskResource($task);
    }

    public function destroy(Request $request, Task $task): JsonResponse
    {
        $request->user()->tokenCan('tasks:write') || abort(403);

        $task->delete();

        return response()->json(null, 204);
    }
}

6. API Resources

// app/Http/Resources/V1/ProjectResource.php
<?php

declare(strict_types=1);

namespace App\Http\Resources\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProjectResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'description' => $this->description,
            'status' => $this->status,
            'due_date' => $this->due_date?->toDateString(),
            'tasks_count' => $this->whenCounted('tasks'),
            'completion_percentage' => $this->when(
                $this->relationLoaded('tasks'),
                fn () => $this->completionPercentage(),
            ),
            'team' => new TeamResource($this->whenLoaded('team')),
            'tasks' => TaskResource::collection($this->whenLoaded('tasks')),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }
}
// app/Http/Resources/V1/TaskResource.php
<?php

declare(strict_types=1);

namespace App\Http\Resources\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class TaskResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'status' => $this->status,
            'priority' => $this->priority,
            'position' => $this->position,
            'due_date' => $this->due_date?->toDateString(),
            'is_overdue' => $this->isOverdue(),
            'project' => new ProjectResource($this->whenLoaded('project')),
            'assignee' => $this->when(
                $this->relationLoaded('assignee'),
                fn () => $this->assignee ? [
                    'id' => $this->assignee->id,
                    'name' => $this->assignee->name,
                    'email' => $this->assignee->email,
                ] : null,
            ),
            'created_by' => $this->when(
                $this->relationLoaded('creator'),
                fn () => [
                    'id' => $this->creator->id,
                    'name' => $this->creator->name,
                ],
            ),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }
}

7. Rate Limiting by Plan

// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        $user = $request->user();
        $tenant = $user?->currentTenant;

        // Rate limit by plan
        $maxRequests = match ($tenant?->plan?->slug) {
            'business' => 1000,
            'professional' => 300,
            'starter' => 60,
            default => 30,
        };

        return Limit::perMinute($maxRequests)
            ->by($tenant?->id ?? ($user?->id ?? $request->ip()));
    });
}

8. Webhook System

Allow tenants to receive notifications when events occur:

Migration

// database/migrations/2026_03_21_000001_create_webhooks_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('webhook_endpoints', function (Blueprint $table) {
            $table->id();
            $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
            $table->string('url');
            $table->string('secret', 64);
            $table->json('events'); // ['project.created', 'task.updated', ...]
            $table->boolean('is_active')->default(true);
            $table->timestamp('last_triggered_at')->nullable();
            $table->integer('failure_count')->default(0);
            $table->timestamps();

            $table->index(['tenant_id', 'is_active']);
        });

        Schema::create('webhook_deliveries', function (Blueprint $table) {
            $table->id();
            $table->foreignId('webhook_endpoint_id')->constrained()->cascadeOnDelete();
            $table->string('event');
            $table->json('payload');
            $table->integer('response_code')->nullable();
            $table->text('response_body')->nullable();
            $table->integer('duration_ms')->nullable();
            $table->enum('status', ['pending', 'success', 'failed'])->default('pending');
            $table->integer('attempt')->default(1);
            $table->timestamps();

            $table->index(['webhook_endpoint_id', 'status']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('webhook_deliveries');
        Schema::dropIfExists('webhook_endpoints');
    }
};

Webhook Models

// app/Domain/Api/Models/WebhookEndpoint.php
<?php

declare(strict_types=1);

namespace App\Domain\Api\Models;

use App\Domain\Tenant\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class WebhookEndpoint extends Model
{
    use BelongsToTenant;

    protected $fillable = [
        'tenant_id',
        'url',
        'secret',
        'events',
        'is_active',
    ];

    protected $casts = [
        'events' => 'array',
        'is_active' => 'boolean',
        'last_triggered_at' => 'datetime',
    ];

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

    public function subscribesTo(string $event): bool
    {
        return in_array($event, $this->events, true)
            || in_array('*', $this->events, true);
    }
}

Webhook Dispatcher

// app/Domain/Api/Services/WebhookDispatcher.php
<?php

declare(strict_types=1);

namespace App\Domain\Api\Services;

use App\Domain\Api\Jobs\DeliverWebhookJob;
use App\Domain\Api\Models\WebhookEndpoint;
use App\Domain\Tenant\Models\Tenant;

class WebhookDispatcher
{
    /**
     * Available events.
     */
    public const EVENTS = [
        'project.created',
        'project.updated',
        'project.deleted',
        'task.created',
        'task.updated',
        'task.completed',
        'task.deleted',
        'member.joined',
        'member.left',
    ];

    /**
     * Dispatch webhook to all endpoints subscribed to this event.
     */
    public function dispatch(Tenant $tenant, string $event, array $data): void
    {
        $endpoints = WebhookEndpoint::withoutTenantScope()
            ->where('tenant_id', $tenant->id)
            ->where('is_active', true)
            ->where('failure_count', '<', 10) // Disable after 10 consecutive failures
            ->get();

        foreach ($endpoints as $endpoint) {
            if ($endpoint->subscribesTo($event)) {
                DeliverWebhookJob::dispatch($endpoint, $event, $data);
            }
        }
    }

    /**
     * Helper to dispatch from anywhere in the code.
     */
    public static function fire(string $event, array $data): void
    {
        $tenant = current_tenant();

        if (! $tenant) {
            return;
        }

        app(self::class)->dispatch($tenant, $event, $data);
    }
}

Webhook Delivery Job

// app/Domain/Api/Jobs/DeliverWebhookJob.php
<?php

declare(strict_types=1);

namespace App\Domain\Api\Jobs;

use App\Domain\Api\Models\WebhookDelivery;
use App\Domain\Api\Models\WebhookEndpoint;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;

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

    public int $tries = 3;
    public array $backoff = [30, 300, 3600]; // 30s, 5min, 1h

    public function __construct(
        private readonly WebhookEndpoint $endpoint,
        private readonly string $event,
        private readonly array $data,
    ) {
        $this->queue = 'webhooks';
    }

    public function handle(): void
    {
        $payload = [
            'event' => $this->event,
            'data' => $this->data,
            'timestamp' => now()->toIso8601String(),
            'webhook_id' => uniqid('wh_'),
        ];

        // Create signature
        $signature = hash_hmac(
            'sha256',
            json_encode($payload),
            $this->endpoint->secret,
        );

        $delivery = WebhookDelivery::create([
            'webhook_endpoint_id' => $this->endpoint->id,
            'event' => $this->event,
            'payload' => $payload,
            'status' => 'pending',
            'attempt' => $this->attempts(),
        ]);

        $startTime = microtime(true);

        try {
            $response = Http::timeout(10)
                ->withHeaders([
                    'Content-Type' => 'application/json',
                    'X-Webhook-Signature' => $signature,
                    'X-Webhook-Event' => $this->event,
                    'User-Agent' => 'SaaSApp-Webhook/1.0',
                ])
                ->post($this->endpoint->url, $payload);

            $duration = (int) ((microtime(true) - $startTime) * 1000);

            $delivery->update([
                'response_code' => $response->status(),
                'response_body' => substr($response->body(), 0, 1000),
                'duration_ms' => $duration,
                'status' => $response->successful() ? 'success' : 'failed',
            ]);

            if ($response->successful()) {
                $this->endpoint->update([
                    'last_triggered_at' => now(),
                    'failure_count' => 0,
                ]);
            } else {
                $this->endpoint->increment('failure_count');
            }
        } catch (\Exception $e) {
            $duration = (int) ((microtime(true) - $startTime) * 1000);

            $delivery->update([
                'response_body' => $e->getMessage(),
                'duration_ms' => $duration,
                'status' => 'failed',
            ]);

            $this->endpoint->increment('failure_count');

            throw $e; // Re-throw so the job retries
        }
    }
}

9. Integrating Webhooks with Events

// app/Domain/Project/Listeners/DispatchProjectWebhook.php
<?php

declare(strict_types=1);

namespace App\Domain\Project\Listeners;

use App\Domain\Api\Services\WebhookDispatcher;
use App\Domain\Project\Events\ProjectCreated;

class DispatchProjectWebhook
{
    public function handle(ProjectCreated $event): void
    {
        WebhookDispatcher::fire('project.created', [
            'project' => [
                'id' => $event->project->id,
                'name' => $event->project->name,
                'status' => $event->project->status,
                'created_at' => $event->project->created_at->toIso8601String(),
            ],
        ]);
    }
}

Register in EventServiceProvider:

protected $listen = [
    ProjectCreated::class => [
        DispatchProjectWebhook::class,
    ],
];

10. Webhook Management API

// app/Http/Controllers/Api/V1/WebhookController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1;

use App\Domain\Api\Models\WebhookEndpoint;
use App\Domain\Api\Services\WebhookDispatcher;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class WebhookController extends Controller
{
    public function index(): JsonResponse
    {
        $endpoints = WebhookEndpoint::with('deliveries', function ($q) {
            $q->latest()->limit(5);
        })->get();

        return response()->json(['data' => $endpoints]);
    }

    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'url' => 'required|url|max:500',
            'events' => 'required|array|min:1',
            'events.*' => 'string|in:' . implode(',', WebhookDispatcher::EVENTS) . ',*',
        ]);

        $endpoint = WebhookEndpoint::create([
            'tenant_id' => current_tenant()->id,
            'url' => $validated['url'],
            'secret' => Str::random(64),
            'events' => $validated['events'],
            'is_active' => true,
        ]);

        return response()->json([
            'data' => $endpoint,
            'secret' => $endpoint->secret, // Only shown once
        ], 201);
    }

    public function show(WebhookEndpoint $webhook): JsonResponse
    {
        $webhook->load(['deliveries' => fn ($q) => $q->latest()->limit(20)]);

        return response()->json(['data' => $webhook]);
    }

    public function update(Request $request, WebhookEndpoint $webhook): JsonResponse
    {
        $validated = $request->validate([
            'url' => 'sometimes|url|max:500',
            'events' => 'sometimes|array|min:1',
            'events.*' => 'string|in:' . implode(',', WebhookDispatcher::EVENTS) . ',*',
            'is_active' => 'sometimes|boolean',
        ]);

        $webhook->update($validated);

        return response()->json(['data' => $webhook]);
    }

    public function destroy(WebhookEndpoint $webhook): JsonResponse
    {
        $webhook->delete();

        return response()->json(null, 204);
    }
}

11. API Documentation

Create an endpoint to serve API docs:

// app/Http/Controllers/Api/ApiDocsController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;

class ApiDocsController extends Controller
{
    public function __invoke()
    {
        return response()->json([
            'name' => 'SaaS App API',
            'version' => 'v1',
            'base_url' => url('/api/v1'),
            'authentication' => [
                'type' => 'Bearer Token',
                'header' => 'Authorization: Bearer {token}',
                'tenant_header' => 'X-Tenant: {tenant-slug}',
            ],
            'rate_limits' => [
                'starter' => '60 requests/minute',
                'professional' => '300 requests/minute',
                'business' => '1000 requests/minute',
            ],
            'endpoints' => [
                'projects' => [
                    'list' => 'GET /api/v1/projects',
                    'create' => 'POST /api/v1/projects',
                    'show' => 'GET /api/v1/projects/{id}',
                    'update' => 'PUT /api/v1/projects/{id}',
                    'delete' => 'DELETE /api/v1/projects/{id}',
                ],
                'tasks' => [
                    'list' => 'GET /api/v1/projects/{project_id}/tasks',
                    'create' => 'POST /api/v1/projects/{project_id}/tasks',
                    'show' => 'GET /api/v1/tasks/{id}',
                    'update' => 'PUT /api/v1/tasks/{id}',
                    'delete' => 'DELETE /api/v1/tasks/{id}',
                ],
                'webhooks' => [
                    'list' => 'GET /api/v1/webhooks',
                    'create' => 'POST /api/v1/webhooks',
                    'show' => 'GET /api/v1/webhooks/{id}',
                    'update' => 'PUT /api/v1/webhooks/{id}',
                    'delete' => 'DELETE /api/v1/webhooks/{id}',
                ],
            ],
            'webhook_events' => [
                'project.created', 'project.updated', 'project.deleted',
                'task.created', 'task.updated', 'task.completed', 'task.deleted',
                'member.joined', 'member.left',
            ],
        ]);
    }
}

12. Testing the API

// tests/Feature/Api/ProjectApiTest.php
<?php

use App\Domain\Billing\Models\Plan;
use App\Domain\Project\Models\Project;
use App\Domain\Tenant\Actions\CreateTenantAction;
use App\Domain\User\Models\User;
use App\Providers\TenantServiceProvider;

beforeEach(function () {
    $this->seed(\Database\Seeders\PlanSeeder::class);

    $this->user = User::factory()->create();
    $this->tenant = (new CreateTenantAction())->execute($this->user, 'API Test Co');
    $this->tenant->update(['plan_id' => Plan::where('slug', 'business')->first()->id]);
    TenantServiceProvider::setTenant($this->tenant);

    $this->token = $this->user->createToken('test', ['projects:read', 'projects:write'])->plainTextToken;
});

test('can list projects via API', function () {
    Project::create(['name' => 'Test Project', 'tenant_id' => $this->tenant->id]);

    $this->withHeaders([
        'Authorization' => "Bearer {$this->token}",
        'Accept' => 'application/json',
    ])
        ->get('/api/v1/projects')
        ->assertOk()
        ->assertJsonCount(1, 'data')
        ->assertJsonFragment(['name' => 'Test Project']);
});

test('can create project via API', function () {
    $this->withHeaders([
        'Authorization' => "Bearer {$this->token}",
        'Accept' => 'application/json',
    ])
        ->post('/api/v1/projects', [
            'name' => 'New API Project',
            'description' => 'Created via API',
        ])
        ->assertCreated()
        ->assertJsonFragment(['name' => 'New API Project']);

    $this->assertDatabaseHas('projects', [
        'name' => 'New API Project',
        'tenant_id' => $this->tenant->id,
    ]);
});

test('cannot access without token', function () {
    $this->getJson('/api/v1/projects')
        ->assertUnauthorized();
});

test('token without write ability cannot create', function () {
    $readToken = $this->user->createToken('read-only', ['projects:read'])->plainTextToken;

    $this->withHeaders([
        'Authorization' => "Bearer {$readToken}",
        'Accept' => 'application/json',
    ])
        ->post('/api/v1/projects', ['name' => 'Should Fail'])
        ->assertForbidden();
});

test('rate limiting works', function () {
    // Simulate many requests
    for ($i = 0; $i < 61; $i++) {
        $response = $this->withHeaders([
            'Authorization' => "Bearer {$this->token}",
            'Accept' => 'application/json',
        ])->get('/api/v1/projects');
    }

    // The 61st request would be rate limited (starter = 60/min)
    // Note: business plan = 1000/min so this test needs adjustment
});

Summary

In Part 7, we built:

  • REST API with authentication (Sanctum), versioning (v1), resources
  • Token abilities for granular access control
  • API tenant resolution via X-Tenant header
  • Rate limiting by plan
  • Complete webhook system: endpoints, events, delivery, retry
  • HMAC signature for webhook security
  • API documentation endpoint

Key takeaways:

  • API access should be a premium feature (Business plan)
  • Rate limiting by plan creates an incentive to upgrade
  • Webhooks need retry logic and automatic disabling after repeated failures
  • Always sign webhook payloads with HMAC

Next up (finale): Deploy Production & Scaling — deploying the SaaS application to production with SSL, monitoring, and scaling strategies.

Comments