REST API guided project

API Controllers

Step 5 / 8 25-40 min JSON API SQLite to MySQL path

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.

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.

Project files

Download completed API

This download does not include the CorianderPHP framework. Start from a CorianderPHP project, then copy these app-owned files into it.

Download completed API