You’re reading the v0.1 development preview. APIs may change.
~ 4 min readUpdated 2026-10-29

Build a PHP REST API with Elang

This guide builds a small read-only catalog API and a separate input-validation endpoint. You will return JSON, accept a route parameter, use HTTP status codes, and reject malformed input. The catalog is deliberately in memory so you can understand the HTTP behavior before adding database persistence.

Create the application

Run this from the parent directory for the new application:

composer create-project --repository=https://elangphp.com/packages elang/elang catalog-api 0.1.0-dev
cd catalog-api
php elang doctor

Keep this terminal in the new application directory. Replace routes/web.php with the complete file below.

Register the endpoints

<?php
declare(strict_types=1);

use Elang\Http\HttpException;
use Elang\Http\Middleware\RateLimitMiddleware;
use Elang\Validation\Validator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints as Assert;

$catalog = [
    1 => ['id' => 1, 'title' => 'Learn routing'],
    2 => ['id' => 2, 'title' => 'Validate input'],
];

$router->group('/api', [RateLimitMiddleware::class], function ($router) use ($catalog) {
    $router->get('/lessons', fn () => ['data' => array_values($catalog)]);

    $router->get('/lessons/{id}', function (int $id) use ($catalog) {
        if (!isset($catalog[$id])) {
            throw new HttpException(404, 'This lesson does not exist.');
        }
        return ['data' => $catalog[$id]];
    })->where('id', '[0-9]+');

    $router->post('/validate-contact', function (Request $request, Validator $validator) {
        if (!str_contains($request->headers->get('Content-Type', ''), 'application/json')) {
            throw new HttpException(415, 'Send an application/json request.');
        }
        try {
            $data = json_decode($request->getContent(), true, 32, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            throw new HttpException(400, 'Invalid JSON body.');
        }
        if (!is_array($data) || array_is_list($data) && $data !== []) {
            throw new HttpException(400, 'Expected a JSON object.');
        }
        $valid = $validator->validate($data, [
            'name' => [new Assert\NotBlank(), new Assert\Type('string'), new Assert\Length(max: 80)],
            'email' => [new Assert\NotBlank(), new Assert\Type('string'), new Assert\Email()],
        ]);
        return ['message' => 'Input accepted; nothing was stored.', 'data' => $valid];
    });
});

All values and routes are defined in the file. There are no missing controller or database steps. The validation endpoint does not create an account, send email, or persist a record.

Start the server

php elang serve

Open a second terminal for requests, leaving the server running in the first. On Windows PowerShell, use curl.exe for the cURL executable, or an HTTP client that lets you set the method, headers, and JSON body.

Read a collection and an item

curl http://127.0.0.1:8000/api/lessons
curl http://127.0.0.1:8000/api/lessons/1

The first response contains both lesson objects in data. The second contains the lesson with ID 1. Requesting /api/lessons/999 returns 404 with a message and request ID. A nonnumeric identifier fails the route requirement.

Send valid JSON

In a POSIX shell, run:

curl -X POST http://127.0.0.1:8000/api/validate-contact \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ada","email":"ada@example.com"}'

Alternatively, use an HTTP client: choose POST, enter the URL, add Content-Type: application/json, and paste the JSON object into its raw request body. Expected status: 200. The response repeats only the validated fields and explains that nothing was stored.

Exercise the failure cases

Request Expected result
Malformed JSON 400 with a safe message
Missing JSON Content-Type 415
Invalid email 422 with an email field error
POST to the GET collection route 405 with Allow
Unknown lesson ID 404
Requests beyond the configured limit 429 with Retry-After

Use these cases as the beginning of an automated API test suite. A successful request alone does not show whether the input boundary behaves correctly.

Add persistence deliberately

The next step is database access, followed by migrations. Bind all user values as query parameters. Decide who may create or change a record before adding write endpoints to a public application.

This sample has no identity or authorization system because it only exposes an application-controlled catalog and validates a nonpersistent payload. Adding authentication, ownership checks, and durable writes is a separate design task.

Continue learning

Follow the complete task board for an HTML application with state-changing forms, session storage, CSRF protection, and validation feedback. Use the HTTP response reference when you need custom statuses or headers.

Need a hand?

Check the troubleshooting guide, look up a term in the glossary, or work through the complete task board project.

Search Elang documentation

Start typing to find your next step.