Xây dựng SaaS với Laravel (Phần 7): REST API & Webhooks
·
18 min read
Ở Phần 6, chúng ta đã xây dựng admin dashboard. Giờ đến phần mà mọi SaaS nghiêm túc cần có: API cho third-party integrations và webhooks để notify external systems.
1. API Architecture
┌──────────────────────────────────────────┐
│ API Gateway │
│ │
│ Request → Auth → Rate Limit → Tenant │
│ ↓ │
│ ┌────────────────────────┐ │
│ │ API Controller │ │
│ │ ┌──────┐ ┌──────┐ │ │
│ │ │ v1 │ │ v2 │ │ │
│ │ └──────┘ └──────┘ │ │
│ └────────────────────────┘ │
│ ↓ │
│ API Resources → JSON Response │
└──────────────────────────────────────────┘
2. API Authentication với Sanctum
Cấu hình 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()),
]);
// Kiểm tra plan cho phép API access
$tenant = current_tenant();
if (! $tenant->hasFeature('api_access')) {
return back()->withErrors([
'api' => 'Plan hiện tại không hỗ trợ API. Vui lòng nâng cấp lên 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 đã bị xóa.');
}
}
3. API Routes với 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;
// Hoặc cho phép specify tenant qua 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 theo 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 theo 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
Cho phép tenants nhận notifications khi events xảy ra:
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 cho tất cả endpoints đã subscribe event này.
*/
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 sau 10 failures liên tiếp
->get();
foreach ($endpoints as $endpoint) {
if ($endpoint->subscribesTo($event)) {
DeliverWebhookJob::dispatch($endpoint, $event, $data);
}
}
}
/**
* Helper để dispatch từ anywhere trong 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_'),
];
// Tạo 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 để job retry
}
}
}
9. Tích hợp Webhooks vào 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(),
],
]);
}
}
Đăng ký trong 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, // Chỉ hiển thị 1 lần
], 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
Tạo endpoint để 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 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 () {
// Giải lập nhiều requests
for ($i = 0; $i < 61; $i++) {
$response = $this->withHeaders([
'Authorization' => "Bearer {$this->token}",
'Accept' => 'application/json',
])->get('/api/v1/projects');
}
// Request thứ 61 bị rate limited (starter = 60/min)
// Note: business plan = 1000/min nên test này cần adjust
});
Tổng kết
Ở phần 7, chúng ta đã xây dựng:
- REST API với authentication (Sanctum), versioning (v1), resources
- Token abilities cho granular access control
- API tenant resolution qua header
X-Tenant - Rate limiting theo plan
- Webhook system hoàn chỉnh: endpoints, events, delivery, retry
- HMAC signature cho webhook security
- API documentation endpoint
Key takeaways:
- API access nên là premium feature (Business plan)
- Rate limit theo plan tạo incentive upgrade
- Webhook cần retry logic và automatic disable sau nhiều failures
- Luôn sign webhook payload với HMAC
Phần tiếp theo (cuối cùng): Deploy Production & Scaling — deploy ứng dụng SaaS lên production, SSL, monitoring, và scaling strategies.