API Patterns
Standard Request Flow
Route → Controller → FormRequest → Service → Model → Resource → JSON ResponseEvery API endpoint follows this pattern. The layers are:
| Layer | Responsibility | Location |
|---|---|---|
| Route | URL mapping, middleware | Modules/*/routes/api.php |
| Controller | Thin orchestration, HTTP concerns | Modules/*/app/Http/Controllers/ |
| FormRequest | Input validation | Modules/*/app/Http/Requests/ |
| Service | Business logic, external integrations | Modules/*/app/Services/ |
| Model | Data access, relationships, scopes | Modules/*/app/Models/ |
| Resource | JSON transformation | Modules/*/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
SignupResourcewith 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=2Custom 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
| Middleware | Purpose |
|---|---|
ForceJsonResponse | Sets Accept: application/json on all requests |
CorsMiddleware | Adds CORS headers (Access-Control-Allow-Origin: *) |
PropagateContextMiddleware | Adds correlation_id and trace_id to Laravel Context for log tracing |
Route-Level Middleware
| Middleware | Purpose |
|---|---|
auth:legacy-jwt | Requires valid JWT token |
OptionalLegacyJwtAuth | Attempts JWT auth but allows anonymous access |
ValidateAuthorizationHeader | Validates shared secret for webhooks |
RequireApiKey | External API key validation |
RequireAuthToken | External bearer token validation |
RequireScope | External API scope checking |
SubstituteBindings | Laravel 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