API Controllers
Controllers should stay thin. They read request data, call the service layer, and return JSON. The service and repository own business rules and database access.
You are here in the flow:
Route
Selects the controller method for the HTTP request.
ShelterAnimalController
Reads request data and calls the service.
AnimalService
Runs the API workflow and returns data or errors.
ApiJson
Builds the PSR-7 JSON response.
JSON helper
Create src/Modules/ShelterApi/ApiJson.php:
<?php
declare(strict_types=1);
namespace Modules\ShelterApi;
use Nyholm\Psr7\Response;
final class ApiJson
{
public static function response(array $payload, int $status = 200): Response
{
return new Response(
$status,
['Content-Type' => 'application/json; charset=utf-8'],
json_encode($payload, JSON_THROW_ON_ERROR)
);
}
}
Animal controller
You created src/ApiControllers/ShelterAnimalController.php in the project structure step. Replace its generated body with this implementation:
<?php
declare(strict_types=1);
namespace ApiControllers;
use Modules\ShelterApi\AnimalService;
use Modules\ShelterApi\ApiJson;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class ShelterAnimalController
{
public function __construct(private readonly AnimalService $animals = new AnimalService())
{
}
public function index(ServerRequestInterface $request): ResponseInterface
{
return ApiJson::response([
'data' => $this->animals->list($request->getQueryParams()),
]);
}
public function show(ServerRequestInterface $request): ResponseInterface
{
$animal = $this->animals->find((int) $request->getAttribute('id'));
return $animal === null
? ApiJson::response(['error' => ['code' => 'not_found', 'message' => 'Animal not found.']], 404)
: ApiJson::response(['data' => $animal]);
}
public function store(ServerRequestInterface $request): ResponseInterface
{
$animal = $this->animals->create($this->jsonBody($request));
return ApiJson::response(['data' => $animal], 201);
}
public function update(ServerRequestInterface $request): ResponseInterface
{
$animal = $this->animals->update((int) $request->getAttribute('id'), $this->jsonBody($request));
return ApiJson::response(['data' => $animal]);
}
public function destroy(ServerRequestInterface $request): ResponseInterface
{
$this->animals->archive((int) $request->getAttribute('id'));
return ApiJson::response(['data' => ['deleted' => true]]);
}
private function jsonBody(ServerRequestInterface $request): array
{
$body = json_decode((string) $request->getBody(), true);
return is_array($body) ? $body : [];
}
}
Lookup controller
You also created src/ApiControllers/ShelterLookupController.php in the project structure step. Keep it read-only:
<?php
declare(strict_types=1);
namespace ApiControllers;
use Modules\ShelterApi\AnimalService;
use Modules\ShelterApi\ApiJson;
use Psr\Http\Message\ResponseInterface;
final class ShelterLookupController
{
public function species(): ResponseInterface
{
return ApiJson::response(['data' => (new AnimalService())->species()]);
}
public function shelters(): ResponseInterface
{
return ApiJson::response(['data' => (new AnimalService())->shelters()]);
}
}
Why services matter
If controllers directly query SQL, every endpoint starts duplicating rules. The service gives you one place for allowed statuses, validation errors, soft deletes, and response shaping.
Checkpoint
Call:
GET /api/shelter/animals/1
You should receive a JSON object with a top-level data key. If the response shape is inconsistent, check ApiJson. If the endpoint finds the wrong animal, check the service and repository path.