Phase 3 Comprehensive Exercise—ShopMetrics API and Real-Time Notifications
Phase 3 Comprehensive Exercise is "Delivering Advanced Features"—integrating authentication, APIs, middleware, events, and storage to build a production-grade API.
1. What You'll Learn
- Complete Process for Sanctum Token API Authentication
- 10+ API resource endpoints: Tenant/Product/Order/Subscription CRUD
- Custom middleware: TenantResolver/RateLimiter/CorsHandler
- Order event broadcasting: Real-time WebSocket push notifications to Alice, Bob, and Charlie
- Uploading Product Images to S3 and Downloading Pre-signed URLs
2. Alice's Phase 3 Acceptance Story
(1) Pain Point: The material from the six lessons is fragmented and cannot be assembled into a complete API
After completing Phase 3—Sanctum in Lesson 15, Resources in Lesson 16, and Middleware in Lesson 17—Alice didn't know how to put them all together into a complete API. Bob asked her to write a RESTful API, and she discovered that features like authentication, resource conversion, middleware, event broadcasting, and file uploads needed to work together. She understood each one individually, but when combined, everything became chaotic.
(2) Solutions to the Comprehensive Exercises
In this lesson, we'll build a complete RESTful API from scratch—with each endpoint featuring authentication, tenant isolation, rate limiting, resource mapping, and event broadcasting—to ultimately deliver a production-grade API system.
# Phase 3 deliverable: Complete RESTful API with real-time features
php artisan migrate:fresh --seed
php artisan queue:work &
php artisan storage:link
# → 10+ endpoints, auth, rate limiting, broadcasting all working
(3) Revenue
Once Alice finished, she had a complete ShopMetrics API—fully integrated from authentication to broadcasting—that could be delivered directly to the front-end team.
3. API Authentication Layer
(1) Sanctum Token Authentication Process
flowchart TD
A[Client] --> B["POST /api/auth/login<br/>(email+password)"]
B --> C["Sanctum creates Token"]
C --> D["Return plainTextToken"]
D --> E["Client stores Token"]
E --> F["API requests with<br/>Authorization: Bearer {token}"]
F --> G["Sanctum middleware<br/>resolves User"]
G --> H[TenantResolve middleware]
H --> I[Controller]
(2) Authentication Endpoint
// routes/api.php
Route::prefix('auth')->group(function () {
Route::post('/register', [Auth\RegisterController::class, 'register']);
Route::post('/login', [Auth\ApiTokenController::class, 'login']);
Route::post('/forgot-password', [Auth\PasswordResetController::class, 'sendResetLink']);
Route::post('/reset-password', [Auth\PasswordResetController::class, 'reset']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $r) => new UserResource($r->user()));
Route::post('/logout', [Auth\ApiTokenController::class, 'logout']);
Route::apiResource('tokens', Auth\TokenController::class)->only(['store', 'index', 'destroy']);
});
});
| Endpoint | Method | Authentication | Description |
|---|---|---|---|
| /auth/register | POST | ❌ | Register |
| /auth/login | POST | ❌ | Get Token |
| /auth/user | GET | ✅ | Current User |
| /auth/logout | POST | ✅ | Revoke Token |
| /auth/tokens | POST | ✅ | Create a new token |
| /auth/tokens | GET | ✅ | List Tokens |
| /auth/tokens/{id} | DELETE | ✅ | Delete Token |
(1) ▶ Example: Complete Implementation of ShopMetrics Token Authentication
// app/Http/Controllers/Auth/ApiTokenController.php
class ApiTokenController extends Controller
{
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => 'required|email',
'password' => 'required|string',
'device_name' => 'sometimes|string|max:255',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['Invalid credentials.'],
]);
}
if (!$user->tenant_id || $user->tenant->status !== 'active') {
throw ValidationException::withMessages([
'email' => ['Account is not active.'],
]);
}
$abilities = match ($user->role) {
'super_admin' => ['*'],
'tenant_owner' => ['read', 'write', 'manage-users'],
'analyst' => ['read'],
default => [],
};
$token = $user->createToken(
$request->device_name ?? 'api-token',
$abilities,
);
return response()->json([
'user' => new UserResource($user),
'token' => $token->plainTextToken,
'abilities' => $abilities,
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Token revoked.']);
}
}
Output:
// Execution Successful
4. API Resource Endpoints
(1) Complete List of Endpoints
| Endpoint | Method | Resource | Middleware |
|---|---|---|---|
| /v1/shops | GET | ShopResource::collection | auth,tenant,throttle |
| /v1/shops | POST | ShopResource | auth,tenant,throttle,role:owner |
| /v1/shops/{id} | GET | ShopResource | auth,tenant |
| /v1/shops/{id} | PUT | ShopResource | auth,tenant,role:owner |
| /v1/shops/{id} | DELETE | - | auth,tenant,role:owner |
| /v1/shops/{id}/products | GET | ProductResource::collection | auth,tenant |
| /v1/products | POST | ProductResource | auth,tenant,role:owner |
| /v1/products/{id} | GET | ProductResource | auth,tenant |
| /v1/products/{id} | PUT | ProductResource | auth,tenant,role:owner |
| /v1/orders | GET | OrderResource::collection | auth,tenant |
| /v1/orders/{id} | GET | OrderResource | auth,tenant |
| /v1/orders/{id}/status | PATCH | OrderResource | auth,tenant,role:owner |
| /v1/analytics/overview | GET | AnalyticsResource | auth,tenant,ability:read |
| /v1/reports/generate | POST | ReportResource | auth,tenant,ability:write |
(2) Route Definitions
// routes/api.php
Route::middleware(['auth:sanctum', 'tenant.resolve', 'throttle:tenant-api'])
->prefix('v1')->name('api.v1.')->group(function () {
// Shops
Route::apiResource('shops', Api\V1\ShopController::class);
Route::post('shops/{shop}/logo', Api\V1\ShopLogoController::class)->name('shops.logo');
// Products
Route::apiResource('products', Api\V1\ProductController::class);
// Orders
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show']);
Route::patch('orders/{order}/status', [Api\V1\OrderController::class, 'updateStatus']);
// Analytics & Reports
Route::get('analytics/overview', [Api\V1\AnalyticsController::class, 'overview']);
Route::post('reports/generate', [Api\V1\ReportController::class, 'generate']);
// Media
Route::post('media/upload', [Api\V1\MediaController::class, 'upload']);
Route::post('media/presign', [Api\V1\MediaController::class, 'presign']);
Route::get('media/{media}/download', [Api\V1\MediaController::class, 'download']);
});
(1) ▶ Example: ShopMetrics Order API Endpoint
// app/Http/Controllers/Api/V1/OrderController.php
class OrderController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Order::where('tenant_id', tenant()->id)
->with(['shop', 'user'])
->withCount('items');
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('shop_id')) {
$query->where('shop_id', $request->shop_id);
}
if ($request->filled('date_from')) {
$query->where('created_at', '>=', $request->date('date_from'));
}
$orders = $query->latest()->paginate($request->integer('per_page', 15));
return OrderResource::collection($orders);
}
public function show(Order $order): JsonResponse
{
$this->authorize('view', $order);
$order->load(['items.product', 'shop', 'user']);
return new OrderResource($order);
}
public function updateStatus(Request $request, Order $order): JsonResponse
{
$this->authorize('update', $order);
$validated = $request->validate([
'status' => 'required|in:processing,completed,cancelled,refunded',
]);
$oldStatus = $order->status;
$order->update(['status' => $validated['status']]);
event(new OrderStatusChanged($order, $oldStatus, $validated['status']));
return new OrderResource($order->fresh());
}
}
Output:
// Execution Successful
5. Custom Middleware Layer
(1) ▶ Example: The ShopMetrics Middleware Architecture
// app/Http/Middleware/TenantResolve.php
class TenantResolve
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user?->tenant_id) abort(403, 'No tenant.');
$tenant = $user->tenant;
if ($tenant->status !== 'active') abort(403, 'Tenant inactive.');
app()->instance(Tenant::class, $tenant);
return $next($request);
}
}
// app/Http/Middleware/CheckAbility.php
class CheckAbility
{
public function handle(Request $request, Closure $next, string $ability): Response
{
if ($request->user()->tokenCan('*') || $request->user()->tokenCan($ability)) {
return $next($request);
}
abort(403, "Missing ability: {$ability}");
}
}
// app/Http/Middleware/EnsureSubscriptionActive.php
class EnsureSubscriptionActive
{
public function handle(Request $request, Closure $next): Response
{
$tenant = app()->make(Tenant::class);
if (!$tenant->subscription?->isActive()) {
abort(402, 'Active subscription required.');
}
return $next($request);
}
}
Output:
// Execution Successful
(2) ▶ Example: ShopMetrics Rate Limiting Configuration
// app/Providers/AppServiceProvider.php
public function boot(): void
{
RateLimiter::for('tenant-api', function (Request $request) {
$tenant = app()->make(Tenant::class);
$plan = $tenant->plan ?? null;
return Limit::perMinute(match ($plan->slug ?? 'starter') {
'enterprise' => 600,
'pro' => 120,
default => 60,
})->by($tenant->id);
});
}
Output:
// Execution Successful
6. Event Broadcasting Integration
(1) Broadcast Events
// app/Events/OrderPlaced.php
class OrderPlaced implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('tenant.' . $this->order->tenant_id)];
}
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'total' => (float) $this->order->total,
'shop_name' => $this->order->shop->name,
];
}
public function broadcastAs(): string { return 'order.placed'; }
}
(2) Front-end Echo Listening
// resources/js/app.js
window.Echo.private(`tenant.${tenantId}`)
.listen('.order.placed', (e) => {
showNotification(`New order: ${e.order_number} ($${e.total})`);
})
.listen('.order.status_changed', (e) => {
updateOrderRow(e.order_id, e.new_status);
});
7. S3 File Upload Integration
(1) ▶ Example: ShopMetrics Image Upload Endpoint
// app/Http/Controllers/Api/V1/ShopLogoController.php
class ShopLogoController extends Controller
{
public function __invoke(Request $request, Shop $shop): JsonResponse
{
$this->authorize('update', $shop);
$validated = $request->validate([
'logo' => 'required|image|mimes:jpeg,png,webp|max:2048',
]);
if ($shop->logo_path) {
Storage::disk('s3')->delete($shop->logo_path);
}
$path = $request->file('logo')->store(
"shops/{$shop->id}/logos",
's3',
);
$shop->update(['logo_path' => $path]);
return response()->json([
'message' => 'Logo uploaded.',
'logo_url' => Storage::disk('s3')->url($path),
]);
}
}
// app/Http/Controllers/Api/V1/MediaController.php
class MediaController extends Controller
{
public function presign(Request $request): JsonResponse
{
$validated = $request->validate([
'filename' => 'required|string',
'mime_type' => 'required|in:image/jpeg,image/png,image/webp',
]);
$path = 'uploads/' . tenant()->id . '/' . Str::uuid() . '/' . $validated['filename'];
$url = Storage::disk('s3')->temporaryUploadUrl($path, now()->addMinutes(30));
return response()->json(['upload_url' => $url, 'path' => $path]);
}
}
Output:
// Execution Successful
8. Comprehensive Example: ShopMetrics Phase 3 Complete API
// ============================================
// Comprehensive: ShopMetrics Phase 3 Complete API
// Covers: auth, resources, middleware, events, storage
// ============================================
// routes/api.php — Complete API routes
Route::prefix('auth')->group(function () {
Route::post('/register', [Auth\RegisterController::class, 'register']);
Route::post('/login', [Auth\ApiTokenController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $r) => new UserResource($r->user()));
Route::post('/logout', [Auth\ApiTokenController::class, 'logout']);
});
});
Route::middleware(['auth:sanctum', 'tenant.resolve', 'throttle:tenant-api', 'subscription.active'])
->prefix('v1')->group(function () {
Route::apiResource('shops', Api\V1\ShopController::class);
Route::post('shops/{shop}/logo', Api\V1\ShopLogoController::class);
Route::apiResource('products', Api\V1\ProductController::class);
Route::post('products/{product}/images', Api\V1\ProductImageController::class);
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show']);
Route::patch('orders/{order}/status', [Api\V1\OrderController::class, 'updateStatus']);
Route::get('analytics/overview', [Api\V1\AnalyticsController::class, 'overview'])
->middleware('ability:read');
Route::post('reports/generate', [Api\V1\ReportController::class, 'generate'])
->middleware('ability:write');
Route::post('media/upload', [Api\V1\MediaController::class, 'upload']);
Route::post('media/presign', [Api\V1\MediaController::class, 'presign']);
Route::get('media/{media}/download', [Api\V1\MediaController::class, 'download']);
});
// routes/channels.php
Broadcast::channel('tenant.{tenantId}', fn ($user, $tenantId) => $user->tenant_id === (int) $tenantId);
// Broadcasting configuration
// BROADCAST_CONNECTION=redis
// QUEUE_CONNECTION=redis
// Run: php artisan queue:work
// Run: php artisan reverb:start (Laravel 11 built-in WebSocket server)
❓ FAQ
knuckleswtf/scribe) to automatically generate OpenAPI documentation from your code; this is less prone to becoming outdated than manually writing it in Postman. Simply add PHPDoc comments to each controller method.docker run -p 9000:9000 minio/minio server /data. Simply change the AWS_URL to point to localhost:9000.📖 Summary
- Sanctum Token provides stateless authentication for APIs
- Standardized JSON output format for API Resources; nested associations using the
whenLoadedcondition - Middleware stack: CORS → TenantResolve → Auth → Throttle → Ability
- Event broadcasting enables real-time push notifications of order changes to the front end
- S3 file uploads support direct uploads (pre-signed URLs) and server-mediated uploads
- Upon completion of Phase 3, you will have a production-ready RESTful API
📝 Exercises
-
Basic Exercise (⭐): Set up the ShopMetrics API authentication layer, implement the three endpoints (register, login, and logout), and use Postman to test retrieving a token and accessing protected endpoints.
-
Advanced Exercise (⭐⭐): Implement a complete Shop + Product CRUD API, including resource conversion, TenantResolve isolation, and subscription-based rate limiting. Test all endpoints using a Postman Collection.
-
Challenge (⭐⭐⭐): Implement real-time broadcasting of order status changes—the backend's
PATCH /orders/{id}/statusrequest triggers theOrderStatusChangedevent, the frontend'sEcholistens for updates to refresh the UI, and simultaneously logs API requests to the database (using theterminatemiddleware).



