Building a RESTful API in PHP with Slim and PHP-DI: A Complete, Modern Guide

PHP · Slim · RESTful API

Building a RESTful API in PHP with Slim and PHP-DI

PHP is far from dead. In fact, it quietly powers a huge part of todays APIs  from internal microservices to full-blown SaaS backends. When you pair the
Slim Framework with PHP-DI, you get a lightweight yet professional-grade toolkit for building RESTful APIs that are fast,
testable, and easy to maintain.

This long-form guide walks you through how to design and implement a modern RESTful API in PHP with Slim and PHP-DI,
from folder structure and dependency injection to error handling, authentication, and versioning.

Level: Intermediate
Stack: PHP 8+, Slim 4, PHP-DI
Focus: RESTful Architecture & Clean Design

Laptop projecting a holographic cube, representing modular REST API architecture

Why Slim and PHP-DI Are a Powerful Combo for RESTful APIs

When you think of building an API in PHP, the usual suspects appear: Laravel, Symfony, or full-stack frameworks with everything and the kitchen sink.
Theyre great, but they can feel heavy if you just want a clean, focused REST API. This is where Slim shines as a microframework:
it gives you only what you need for HTTP routing and middleware, and stays out of your way.

Pair that with PHP-DI  a powerful dependency injection container  and you get a modular, testable architecture that can scale from
a small internal project to a production-grade API used by thousands of clients.

What is Slim Framework?

Slim is a minimalistic PHP microframework focused on one thing: handling HTTP requests and responses.
It provides routing, middleware support, request/response objects (PSR-7), and integrates well with modern PHP tooling.

Fast routing
PSR-7 & PSR-15 compliant
Middleware pipeline
Framework-agnostic components
Small footprint

What is PHP-DI and Why Use It?

PHP-DI is a dependency injection container that uses autowiring and configuration to resolve class dependencies.
In the context of a RESTful API, it helps you:

  • Separate your infrastructure (framework, HTTP layer) from your domain (business logic).
  • Write controllers that simply declare what they need via constructor type hints.
  • Swap implementations (e.g., database, cache, logger) without touching your controllers or services.
  • Improve testability by injecting mocks and stubs.
In short: Slim gives you a clean HTTP layer; PHP-DI organizes everything beneath it. Together, they encourage
clean, RESTful API design instead of a tangle of static calls and global states.

Planning Your RESTful API: Design First, Code Second

Before installing Slim or writing the first route, invest a little time in design. A RESTful API in PHP benefits from the same
good practices as any other language: clear resources, predictable URLs, consistent responses, and robust error handling.

Identify Your Resources

Start by writing down the nouns your API will expose. For a simple example, imagine a Tasks API:

  • /tasks  collection of tasks.
  • /tasks/{id}  single task.
  • /users/{id}/tasks  tasks belonging to a user.

Map HTTP Verbs to Actions

For truly RESTful semantics, follow standard verb-resource combinations:

HTTP Verb Endpoint Action
GET /tasks List tasks
POST /tasks Create a new task
GET /tasks/{id} Retrieve a specific task
PUT/PATCH /tasks/{id} Update a task
DELETE /tasks/{id} Delete a task

Decide on Versioning Strategy

Versioning from day one avoids painful migrations later:

  • URL versioning: /api/v1/tasks  easy to document and cache.
  • Header-based: Accept: application/vnd.example.v1+json  cleaner URLs, more complex negotiation.

For a Slim-based REST API, URL versioning is usually simpler and plays nicely with route groups.

Project Setup: Slim 4 + PHP-DI + Composer Autoloading

With your design sketched out, its time to structure the project. Well assume youre using PHP 8.1+ and Composer.

Recommended Folder Structure

project-root/
  public/
    index.php
  src/
    Application/
      Http/
        Controllers/
        Middleware/
    Domain/
      Task/
        Task.php
        TaskRepository.php
        TaskService.php
    Infrastructure/
      Persistence/
        PdoTaskRepository.php
    Config/
      dependencies.php
      routes.php
  vendor/
  composer.json
  phpunit.xml

This structure separates the HTTP layer (Application), business logic (Domain), and infrastructure details such as
persistence (Infrastructure). Slim sits mainly in public/index.php and Config/routes.php, while PHP-DI wires
everything via Config/dependencies.php.

Install Dependencies with Composer

composer require slim/slim:^4.0 slim/psr7 php-di/php-di nyholm/psr7

Depending on your preferences, you may choose a different PSR-7 implementation. The key idea is that Slim stays
framework-agnostic while PHP-DI takes care of constructing objects.

Bootstrap the Application (public/index.php)

<?php

use DI\Bridge\Slim\Bridge as SlimBridge;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

require __DIR__ . '/../vendor/autoload.php';

$containerBuilder = new DI\ContainerBuilder();
$containerBuilder->addDefinitions(__DIR__ . '/../Config/dependencies.php');
$container = $containerBuilder->build();

$app = SlimBridge::create($container);

(require __DIR__ . '/../Config/routes.php')($app);

$app->run();

Here we tell Slim to use the PHP-DI container. That means controllers and other classes can be automatically resolved via
type hints, making your RESTful API in PHP both elegant and maintainable.

Wiring Dependencies with PHP-DI

PHP-DI lets you describe how to build complex objects without scattering new calls throughout your code.
Its the backbone of a clean RESTful architecture.

Defining Your Container Configuration

<?php
// Config/dependencies.php

use DI\Container;
use function DI\autowire;
use function DI\create;
use Psr\Log\LoggerInterface;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use App\Domain\Task\TaskRepository;
use App\Infrastructure\Persistence\PdoTaskRepository;

return [
    PDO::class => function () {
        $dsn = 'mysql:host=localhost;dbname=tasks;charset=utf8mb4';
        $username = 'api_user';
        $password = 'secret';

        $pdo = new PDO($dsn, $username, $password);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        return $pdo;
    },

    LoggerInterface::class => function () {
        $logger = new Logger('api');
        $logger->pushHandler(new StreamHandler(__DIR__ . '/../var/log/api.log'));
        return $logger;
    },

    TaskRepository::class => autowire(PdoTaskRepository::class),
];

With this configuration, any class that type-hints TaskRepository or LoggerInterface will automatically receive the
correct implementation. Your controllers become light, focused on translating HTTP requests into application commands.

Defining Routes and Controllers in Slim

With the container configured, your next step is to map HTTP endpoints to controllers that express your APIs behavior.
This is where the restfulness of your design becomes visible.

Registering Routes (Config/routes.php)

<?php
// Config/routes.php

use Slim\App;
use App\Application\Http\Controllers\TaskController;

return function (App $app) {
    $app->group('/api/v1', function (App $app) {
        $app->get('/tasks', [TaskController::class, 'index']);
        $app->post('/tasks', [TaskController::class, 'store']);
        $app->get('/tasks/{id}', [TaskController::class, 'show']);
        $app->put('/tasks/{id}', [TaskController::class, 'update']);
        $app->delete('/tasks/{id}', [TaskController::class, 'destroy']);
    });
};

Implementing a RESTful Controller

<?php
// src/Application/Http/Controllers/TaskController.php

namespace App\Application\Http\Controllers;

use App\Domain\Task\TaskService;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

class TaskController
{
    public function __construct(private TaskService $taskService) {}

    public function index(Request $request, Response $response): Response
    {
        $tasks = $this->taskService->getAll();

        $response->getBody()->write(json_encode($tasks));
        return $response
            ->withHeader('Content-Type', 'application/json')
            ->withStatus(200);
    }

    public function store(Request $request, Response $response): Response
    {
        $data = json_decode((string) $request->getBody(), true) ?? [];
        $task = $this->taskService->create($data);

        $response->getBody()->write(json_encode($task));
        return $response
            ->withHeader('Content-Type', 'application/json')
            ->withStatus(201);
    }

    public function show(Request $request, Response $response, array $args): Response
    {
        $task = $this->taskService->getById((int) $args['id']);

        if (!$task) {
            $response->getBody()->write(json_encode(['error' => 'Task not found']));
            return $response->withHeader('Content-Type', 'application/json')->withStatus(404);
        }

        $response->getBody()->write(json_encode($task));
        return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
    }

    public function update(Request $request, Response $response, array $args): Response
    {
        $data = json_decode((string) $request->getBody(), true) ?? [];
        $task = $this->taskService->update((int) $args['id'], $data);

        if (!$task) {
            $response->getBody()->write(json_encode(['error' => 'Task not found']));
            return $response->withHeader('Content-Type', 'application/json')->withStatus(404);
        }

        $response->getBody()->write(json_encode($task));
        return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
    }

    public function destroy(Request $request, Response $response, array $args): Response
    {
        $deleted = $this->taskService->delete((int) $args['id']);

        if (!$deleted) {
            $response->getBody()->write(json_encode(['error' => 'Task not found']));
            return $response->withHeader('Content-Type', 'application/json')->withStatus(404);
        }

        return $response->withStatus(204);
    }
}

Notice how the controller doesnt know anything about PDO or logging. It simply talks to TaskService, thanks to PHP-DI handling
the wiring. This separation is critical for clean RESTful APIs in PHP.

Domain and Persistence: Keeping Your API Logic Clean

A robust RESTful API is more than routes and controllers. The heart of your application lives in the domain layer  the
place where you model tasks, users, permissions, and business rules.

Modeling a Simple Task Entity

<?php
// src/Domain/Task/Task.php

namespace App\Domain\Task;

class Task implements \JsonSerializable
{
    public function __construct(
        private int $id,
        private string $title,
        private bool $completed = false,
    ) {}

    public function jsonSerialize(): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'completed' => $this->completed,
        ];
    }
}

Repository and Service Layer

<?php
// src/Domain/Task/TaskRepository.php

namespace App\Domain\Task;

interface TaskRepository
{
    public function all(): array;
    public function findById(int $id): ?Task;
    public function save(Task $task): Task;
    public function delete(int $id): bool;
}
<?php
// src/Domain/Task/TaskService.php

namespace App\Domain\Task;

class TaskService
{
    public function __construct(private TaskRepository $repository) {}

    public function getAll(): array
    {
        return $this->repository->all();
    }

    public function getById(int $id): ?Task
    {
        return $this->repository->findById($id);
    }

    public function create(array $data): Task
    {
        $title = trim($data['title'] ?? '');

        if ($title === '') {
            throw new \InvalidArgumentException('Title is required');
        }

        $task = new Task(0, $title, (bool)($data['completed'] ?? false));
        return $this->repository->save($task);
    }

    public function update(int $id, array $data): ?Task
    {
        $task = $this->repository->findById($id);

        if (!$task) {
            return null;
        }

        $title = trim($data['title'] ?? $task->jsonSerialize()['title']);
        $completed = (bool)($data['completed'] ?? $task->jsonSerialize()['completed']);

        $updatedTask = new Task($id, $title, $completed);
        return $this->repository->save($updatedTask);
    }

    public function delete(int $id): bool
    {
        return $this->repository->delete($id);
    }
}

PDO Implementation of the Repository

<?php
// src/Infrastructure/Persistence/PdoTaskRepository.php

namespace App\Infrastructure\Persistence\PdoTaskRepository;

use App\Domain\Task\Task;
use App\Domain\Task\TaskRepository;
use PDO;

class PdoTaskRepository implements TaskRepository
{
    public function __construct(private PDO $pdo) {}

    public function all(): array
    {
        $stmt = $this->pdo->query('SELECT id, title, completed FROM tasks');
        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

        return array_map(fn ($row) => new Task(
            (int) $row['id'],
            $row['title'],
            (bool) $row['completed'],
        ), $rows);
    }

    public function findById(int $id): ?Task
    {
        $stmt = $this->pdo->prepare('SELECT id, title, completed FROM tasks WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$row) {
            return null;
        }

        return new Task((int) $row['id'], $row['title'], (bool) $row['completed']);
    }

    public function save(Task $task): Task
    {
        $data = $task->jsonSerialize();

        if ($data['id'] === 0) {
            $stmt = $this->pdo->prepare('INSERT INTO tasks (title, completed) VALUES (:title, :completed)');
            $stmt->execute([
                'title' => $data['title'],
                'completed' => (int) $data['completed'],
            ]);

            return new Task((int)$this->pdo->lastInsertId(), $data['title'], $data['completed']);
        }

        $stmt = $this->pdo->prepare('UPDATE tasks SET title = :title, completed = :completed WHERE id = :id');
        $stmt->execute([
            'id' => $data['id'],
            'title' => $data['title'],
            'completed' => (int) $data['completed'],
        ]);

        return $task;
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM tasks WHERE id = :id');
        $stmt->execute(['id' => $id]);

        return (bool) $stmt->rowCount();
    }
}

By keeping domain logic decoupled from Slim, your RESTful API can outlive its framework. You could migrate to another HTTP stack in the future
while reusing the same domain services.

Middleware, Error Handling, and Validation in Slim

A production-ready RESTful API in PHP needs more than happy-path controllers. It needs consistent error responses, secure middleware, and
validation that doesnt scatter checks across your code.

Global Error Handling

Slim 4 lets you plug in custom error handlers. Register a global error middleware in index.php to standardize responses.

$errorMiddleware = $app->addErrorMiddleware(true, true, true);
$errorHandler = $errorMiddleware->getDefaultErrorHandler();

$errorHandler->forceContentType('application/json');

For more control, you can register a custom error handler that converts exceptions into JSON API responses with structured error objects
and correlation IDs for logging.

JSON Body Parsing Middleware

While Slim can parse JSON automatically when configured, its common to create lightweight middleware that ensures
application/json requests are validated up front.

Authentication and Authorization

Authentication is often implemented as middleware that checks tokens or API keys before your routes run. For example:

$app->group('/api/v1', function (App $app) {
    // protected routes here
})->add(new JwtAuthMiddleware($container));

RESTful APIs built with Slim and PHP-DI can easily include JWT validation, role-based access control, and rate limiting using dedicated
middleware layers that remain independent of your controllers.

Input Validation Strategies

You can validate input either:

  • Inside services, throwing typed exceptions (InvalidArgumentException, DomainException).
  • Using a dedicated validation library and injecting validators via PHP-DI.
  • Combining request DTOs (Data Transfer Objects) with type-safe validation.

The key is to keep validation rules close to your domain invariants, not spread across controllers.

Performance, Caching, and Pagination for Real-World APIs

Once your API endpoints work, the next challenge is making them performant, predictable, and scalable.
Slim and PHP-DI dont dictate how you do this, but they provide a flexible foundation.

Pagination as a First-Class Concern

Instead of returning thousands of records, embrace pagination from the start. A common pattern is to use query parameters:

  • GET /api/v1/tasks?page=2&per_page=25

Then, return metadata along with the data:

{
  "data": [/* tasks */],
  "meta": {
    "page": 2,
    "per_page": 25,
    "total": 123,
    "total_pages": 5
  }
}

HTTP Caching Headers

RESTful APIs can benefit from basic HTTP caching:

  • ETag headers to let clients cache responses until data changes.
  • Cache-Control headers to define how long responses remain fresh.

Slim lets you set headers easily on responses; you can encapsulate this logic in response factories or dedicated middleware for cleanliness.

Testing and Evolving Your PHP RESTful API

One of the biggest benefits of combining Slim and PHP-DI is how testable your code becomes. Since controllers and services receive dependencies through
constructors, you can replace them in tests with mock implementations.

Unit Testing Services

Testing TaskService just requires a fake TaskRepository. You dont need Slim or a database to verify your business rules.

Functional Testing of Routes

For route-level testing, you can spin up the Slim app in memory, send mock requests, and inspect responses. This helps ensure that your
RESTful API in PHP behaves consistently as it evolves, even as you add new versions (/api/v2) or deprecate old ones.

When Your API Meets AI, Analytics, and SEO

Over time, many organizations start connecting their RESTful APIs to other systems: analytics pipelines, SEO tools, marketing platforms,
or AI-powered services that generate content and insights on top of API data.

The important part from an API-design perspective is to keep your endpoints predictable, documented, and versioned. That makes it much easier for
downstream consumers  whether theyre dashboards, front-ends, or AI agents  to rely on your interface.

FAQ: Building a RESTful API in PHP with Slim and PHP-DI

Slim is a solid choice for production as long as you design your architecture carefully. It is used in many real-world systems where a full-stack
framework would be overkill. Combined with PHP-DI, PSR-7/15 components, and proper logging, Slim can power secure and scalable RESTful APIs
that are easy to test and maintain.

For any new RESTful API in PHP, you should target at least PHP 8.1 or newer. Recent versions bring strong typing, attributes, better performance,
and improved error handling, all of which make your Slim and PHP-DI code cleaner and safer. Many modern libraries are also starting to require
newer PHP versions.

Technically you can build a small API without a DI container, but PHP-DI quickly pays off as your project grows. It keeps your controllers and services
decoupled from infrastructure details, centralizes object construction, and makes testing far easier. For APIs that may evolve over time or be
used by multiple teams, a container is a practical necessity.

The most common approach is to describe your endpoints using the OpenAPI (Swagger) specification, then generate interactive documentation for
developers. You can maintain the specification alongside your PHP code and even use it to generate client SDKs. Clear documentation about
resources, HTTP verbs, status codes, and error formats is essential for any Slim-based API that you expect others to consume.

Security typically combines several layers: HTTPS everywhere, authentication and authorization middleware (for example JWT or OAuth 2.0),
rate limiting, input validation, and proper error handling that avoids leaking sensitive details. Slims middleware pipeline allows you
to implement these concerns cleanly without tangling them with business logic.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top