Skip to Content
API v2API Patterns

API Patterns

Standard Request Flow

Route → Controller → FormRequest → Service → Model → Resource → JSON Response

Every API endpoint follows this pattern. The layers are:

LayerResponsibilityLocation
RouteURL mapping, middlewareModules/*/routes/api.php
ControllerThin orchestration, HTTP concernsModules/*/app/Http/Controllers/
FormRequestInput validationModules/*/app/Http/Requests/
ServiceBusiness logic, external integrationsModules/*/app/Services/
ModelData access, relationships, scopesModules/*/app/Models/
ResourceJSON transformationModules/*/app/Http/Resources/

Real Example: Bills Signup

Route

// Modules/Bills/routes/api.php Route::prefix('bills')->group(function () { Route::post('signup', [BillsController::class, 'signup'])->name('bills.signup'); });

Controller

// Modules/Bills/app/Http/Controllers/BillsController.php public function signup(SignupRequest $request): JsonResponse { $data = $request->validated(); $result = $this->signupService->signup( $data['bedrooms'], $data['address'], // ... all validated fields ); return (new SignupResource($result))->response()->setStatusCode(201); }

The controller:

  • Accepts a SignupRequest (auto-validates)
  • Delegates to SignupService
  • Returns a SignupResource with 201 status

FormRequest

// Modules/Bills/app/Http/Requests/SignupRequest.php class SignupRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'bedrooms' => 'required|integer|min:1', 'address' => 'required|string|max:500', 'huddleAddressId' => 'required|string|max:255', 'water' => 'boolean', 'internetSpeed' => ['nullable', 'string', Rule::in(['400Mbps', '600Mbps', '800Mbps', 'none'])], 'startDate' => 'required|date_format:Y-m-d|after_or_equal:today', 'endDate' => 'required|date_format:Y-m-d|after:startDate', 'price' => 'required|numeric|min:0', 'tenants' => 'required|array|min:1', 'tenants.*.firstName' => 'required|string|max:255', 'tenants.*.lastName' => 'required|string|max:255', 'tenants.*.email' => 'required|email', 'tenants.*.phone' => 'required|string|max:20', 'tenants.*.role' => 'required|in:LEAD,HOUSEMATE', 'tenants.*.isStudent' => 'boolean', ]; } }

Service

// Modules/Bills/app/Services/SignupService.php class SignupService { public function __construct( private HubSpotService $hubSpotService, private UserService $userService, private ExternalWebhookServiceResolver $externalWebhookServiceResolver ) {} public function signup(...): array { // 1. Create or update contract // 2. Create tenants // 3. Sync to HubSpot // 4. Create HubSpot deal // 5. Trigger external webhooks if needed return ['contract' => $contract, 'tenants' => $tenants]; } }

Services use constructor injection for dependencies. Laravel’s container auto-resolves them.

Resource

// Modules/Bills/app/Http/Resources/SignupResource.php class SignupResource extends JsonResource { public function toArray($request): array { return [ 'contractId' => $this['contract']->id, 'tenants' => $this['tenants'], ]; } }

Real Example: Properties Search with Spatie Query Builder

The Properties module uses Spatie Query Builder for filterable, sortable, paginated endpoints.

Route

// Modules/Properties/routes/api.php Route::prefix('v1')->middleware(OptionalJWTAuth::class)->group(function () { Route::get('/houses', [HouseController::class, 'index']); });

Controller with Query Builder

// Modules/Properties/app/Http/Controllers/HouseController.php public function index(Request $request): AnonymousResourceCollection { $houses = QueryBuilder::for(House::withBillsPrice()) ->allowedFilters([ AllowedFilter::exact('city'), AllowedFilter::custom('boundary', new BoundaryFilter), AllowedFilter::custom('price_min', new PriceMinFilter), AllowedFilter::custom('price_max', new PriceMaxFilter), AllowedFilter::exact('bedrooms'), AllowedFilter::exact('bathrooms'), AllowedFilter::custom('is_liked', new IsLikedFilter), AllowedFilter::custom('uob_approved', new UobApprovedFilter), AllowedFilter::custom('academic_year', new AcademicYearFilter), ]) ->allowedSorts([ AllowedSort::custom('popular', new PopularitySort), AllowedSort::custom('recommended', new RecommendedSort), AllowedSort::custom('newest', new NewestSort), AllowedSort::custom('highest_price', new HighestPriceSort), AllowedSort::custom('lowest_price', new LowestPriceSort), ]) ->defaultSort(AllowedSort::custom('recommended', new RecommendedSort)) ->paginate(20); return HouseResource::collection($houses); }

Query string usage:

GET /properties/v1/houses?filter[city]=London&filter[bedrooms]=3&filter[price_min]=100&sort=lowest_price&page=2

Custom Filter

// Modules/Properties/app/Filters/BoundaryFilter.php class BoundaryFilter implements Filter { public function __invoke(Builder $query, $value, string $property): Builder { // Supports two formats: // 1. Bounding box: "north,south,west,east" // 2. Radius: "lat,lng,radius_km" $parts = $this->parseValue($value); if (count($parts) === 4) { return $query->withinCoordinates(...$parts); } if (count($parts) === 3) { return $query->withinLatLngRadius(...$parts); } return $query; } }

Custom Sort

// Modules/Properties/app/Sorts/RecommendedSort.php class RecommendedSort implements Sort { public function __invoke(Builder $query, bool $descending, string $property): Builder { return $query->orderBy('houses.tier', 'asc') ->orderBy('houses.house_priority', 'desc') ->inRandomOrder(); } }

Middleware Stack

Global Middleware

MiddlewarePurpose
ForceJsonResponseSets Accept: application/json on all requests
CorsMiddlewareAdds CORS headers (Access-Control-Allow-Origin: *)
PropagateContextMiddlewareAdds correlation_id and trace_id to Laravel Context for log tracing

Route-Level Middleware

MiddlewarePurpose
auth:legacy-jwtRequires valid JWT token
OptionalLegacyJwtAuthAttempts JWT auth but allows anonymous access
ValidateAuthorizationHeaderValidates shared secret for webhooks
RequireApiKeyExternal API key validation
RequireAuthTokenExternal bearer token validation
RequireScopeExternal API scope checking
SubstituteBindingsLaravel route model binding

Response Patterns

Success Responses

// Single resource return ItemResource::make($item)->response(); // 200 // Resource collection (paginated) return ItemResource::collection($items); // 200 with pagination meta // Created return (new Resource($data))->response()->setStatusCode(201); // No content return response()->json(null, 204);

Error Responses

The fallback route (registered in AppServiceProvider) returns RFC 7807 Problem Details:

{ "type": "https://docs.uk.housr.com/api-reference/errors/not-found", "title": "Not Found", "status": 404, "detail": "The requested endpoint does not exist." }

FormRequest validation failures automatically return 422 with Laravel’s standard validation error format.

Actions Pattern

For single-purpose operations, use Action classes instead of services:

// Modules/Properties/app/Actions/FormatHouseAddressAction.php class FormatHouseAddressAction { public static function handle(House $house): string { // Format logic } }

Actions are static, stateless, and do one thing.

Saloon Connectors

External HTTP integrations use the Saloon library:

// Modules/Bills/app/Http/Integrations/OakmansConnector/OakmansConnector.php class OakmansConnector extends Connector { public function resolveBaseUrl(): string { ... } } // Modules/Bills/app/Http/Integrations/OakmansConnector/Requests/OakmansBillsSignupRequest.php class OakmansBillsSignupRequest extends Request { protected Method $method = Method::POST; // ... }
Last updated on