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.
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.
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.
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:
/taskscollection of tasks./tasks/{id}single task./users/{id}/taskstasks 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/taskseasy to document and cache. - Header-based:
Accept: application/vnd.example.v1+jsoncleaner 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.
For teams that want to go a step further and systematize how they use AI around content, SEO optimization, or analytics dashboards that read from a PHP API,
specialized partners such as data and analytics consultancies
can help design the architecture and governance that sit on top of the raw endpoints.
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.
