The fastest way to build
PHP REST APIs.
FastREST started as a zero-learning-curve framework — drop in a controller, name a method, get a route. That spirit is unchanged in v2. What is changed is everything underneath: full PSR compliance, proper HTTP verb routing, secure parameterised queries, an injectable middleware pipeline, and a DI container that makes every component swappable.
⚡ Quick Start
composer install
cp .env.example .env
# Fill in DB credentials and log config
php -S localhost:8000 public/index.php
composer test
Point a production web server's document root at /public. Everything above it is private.
root /var/www/fastrest/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
🧩 Core Concepts
FastREST v2 is built on five PHP standard interfaces. You never depend on a concrete implementation — only on the interface. This means every layer is replaceable without touching your business logic.
A request flows through the system like this:
Browser / API Client
↓
public/index.php ← Only public file
↓
MiddlewarePipeline ← PSR-15
├── RequestLoggerMiddleware
├── CorsMiddleware
├── AuthMiddleware ← your custom middleware
├── JsonBodyParserMiddleware
└── Router ← PSR-15 final handler
↓
Controller method ← plain class, injected deps
↓
Response::json(...) ← PSR-7 ResponseInterface
↓
public/index.php emits it
🗺 Routing
Routes are defined explicitly in config/routes.php, one line per route. Each maps an HTTP verb and a URI pattern to a controller method.
// config/routes.php
return static function (Router $router): void {
$router->get('/products', [ProductController::class, 'index']);
$router->get('/products/{id}', [ProductController::class, 'show']);
$router->post('/products', [ProductController::class, 'store']);
$router->put('/products/{id}', [ProductController::class, 'update']);
$router->delete('/products/{id}', [ProductController::class, 'destroy']);
$router->any('/health', [HealthController::class, 'check']);
};
Route parameters
Segments in {braces} are captured and available as request attributes:
public function show(ServerRequestInterface $request): ResponseInterface
{
$id = $request->getAttribute('id'); // "42"
}
405 Method Not Allowed with a correct Allow header — you write none of that code.
🎛 Controllers
Controllers are plain PHP classes. No base class to extend, no interface to implement. Dependencies go in the constructor — the DI container resolves them automatically.
namespace FastREST\Controllers;
use FastREST\Database\Database;
use FastREST\Http\Response;
use FastREST\Exceptions\HttpException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
class ProductController
{
public function __construct(
private readonly Database $db,
private readonly LoggerInterface $logger,
) {}
// GET /products
public function index(ServerRequestInterface $request): ResponseInterface
{
$rows = $this->db->select('products');
return Response::json(['status' => 'success', 'data' => $rows]);
}
// GET /products/{id}
public function show(ServerRequestInterface $request): ResponseInterface
{
$id = (int) $request->getAttribute('id');
$rows = $this->db->select('products', [], ['id' => $id]);
if (empty($rows)) {
throw new HttpException(404, "Product $id not found.");
}
return Response::json(['status' => 'success', 'data' => $rows[0]]);
}
}
Reading request data
// Query string: GET /products?status=active
$status = $request->getQueryParams()['status'] ?? null;
// JSON body (parsed by middleware): POST with Content-Type: application/json
$body = (array) $request->getParsedBody();
// Route parameter
$id = $request->getAttribute('id');
// Header
$token = $request->getHeaderLine('Authorization');
📤 Responses & Errors
Response factory
use FastREST\Http\Response;
// 200 OK
return Response::json(['status' => 'success', 'data' => $rows]);
// 201 Created
return Response::json(['status' => 'success'], 201);
// 204 No Content (after DELETE)
return Response::noContent();
// Structured error response
return Response::error('Validation failed', 422, ['field' => 'name is required']);
Throwing HTTP errors
Throw HttpException from anywhere — controllers, middleware, or services. The pipeline catches it and sends a JSON error response with the right status code.
throw new HttpException(404, 'Product not found.');
throw new HttpException(422, 'Missing required fields.');
throw new HttpException(405, 'Method Not Allowed', ['Allow' => 'GET, POST']);
The JSON error shape is always consistent:
{
"status": "error",
"code": 404,
"message": "Product not found."
}
🔗 Middleware
Middleware wraps every request. It can inspect, modify, or short-circuit the request before it reaches a controller, and modify the response on the way back.
Built-in middleware
| Class | What it does |
|---|---|
RequestLoggerMiddleware | Logs method, URI, IP, status code, duration |
JsonBodyParserMiddleware | Decodes application/json bodies into getParsedBody() |
CorsMiddleware | Adds CORS headers; handles OPTIONS preflight (204) |
Writing a middleware
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class AuthMiddleware implements MiddlewareInterface
{
public function __construct(private readonly TokenValidator $validator) {}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
$token = $request->getHeaderLine('Authorization');
if (!$this->validator->isValid($token)) {
throw new HttpException(401, 'Unauthorized');
}
// Attach user to request — available in controllers via getAttribute()
$request = $request->withAttribute('user', $this->validator->getUser($token));
return $handler->handle($request);
}
}
Registering middleware
Order matters — the pipeline runs top to bottom on the request, and bottom to top on the response.
// public/index.php
$pipeline = new MiddlewarePipeline([
$container->get(RequestLoggerMiddleware::class), // 1st — log everything
$container->get(CorsMiddleware::class), // 2nd — CORS headers
$container->get(AuthMiddleware::class), // 3rd — authentication
$container->get(JsonBodyParserMiddleware::class), // 4th — parse body
$router, // last — dispatch
]);
💉 Dependency Injection
Services are wired in config/container.php. The container resolves constructor dependencies automatically — you never call new inside a controller.
// config/container.php
use function DI\create;
use function DI\factory;
use function DI\get;
return [
// Factory — full control
MyService::class => factory(function (): MyService {
return new MyService($_ENV['MY_API_KEY']);
}),
// Autowired — PHP-DI reads the constructor type-hints
OrderService::class => create(OrderService::class)
->constructor(get(Database::class), get(Mailer::class)),
// Interface binding — swap the concrete class here, nothing else changes
CacheInterface::class => get(RedisCache::class),
];
🗄 Database
Safe, parameterised CRUD. Column and table names are validated against an identifier regex. ORDER BY directions are whitelisted. No raw string interpolation anywhere.
INSERT
$db->insert('products', [
'name' => 'Widget',
'price' => 9.99,
'status' => 'active',
]);
// Returns true on success, false on failure.
SELECT
// All rows
$rows = $db->select('products');
// Filtered, ordered, limited
$rows = $db->select(
table: 'products',
columns: ['id', 'name', 'price'],
where: ['status' => 'active'],
order: ['price' => 'DESC'],
limit: 10,
);
UPDATE
$db->update('products', ['price' => 12.99], ['id' => 5]);
DELETE
$db->delete('products', ['id' => 5]);
Parameterised raw query
$rows = $db->preparedQuery(
'SELECT * FROM products WHERE price BETWEEN :min AND :max',
['min' => 5.0, 'max' => 50.0],
);
$db->query($sql) executes raw SQL without binding. Use only for trusted, hard-coded strings — never interpolate user input.
🔨 QueryBuilder
Fluent builder for complex SELECTs — joins, group by, having, multiple conditions.
use FastREST\Database\QueryBuilder;
$rows = (new QueryBuilder('products', ['p.id', 'p.name', 'c.name AS category']))
->leftJoin('categories c', 'p.category_id = c.id')
->where('p.status', '=', 'active')
->orWhere('p.featured', '=', 1)
->where('p.price', '>', 0)
->groupBy('p.id')
->having('COUNT(p.id) > 0')
->orderBy('p.name', 'ASC')
->limit(20)
->execute($db);
Methods reference
| Method | Description |
|---|---|
where($col, $op, $value) | AND WHERE condition |
orWhere($col, $op, $value) | OR WHERE condition |
whereRaw($condition) | Raw AND WHERE (manual binding via withParam) |
leftJoin($table, $on) | LEFT JOIN |
innerJoin($table, $on) | INNER JOIN |
join($type, $table, $on) | Any join type (INNER, LEFT, RIGHT, CROSS…) |
groupBy($column) | GROUP BY |
having($condition) | HAVING |
orderBy($col, $dir) | ORDER BY — direction is whitelisted ASC/DESC |
limit($n) | LIMIT |
toSql() | Returns the SQL string |
getParams() | Returns the bound params array |
execute($db) | Runs the query and returns rows |
Allowed where() operators: =, !=, <>, <, >, <=, >=, LIKE, NOT LIKE, IN, NOT IN, IS NULL, IS NOT NULL
📋 Logging
The logger is a Psr\Log\LoggerInterface — any PSR-3 logger works. Monolog ships as the default. Inject it via constructor and use it normally:
$this->logger->debug('Cache miss', ['key' => $cacheKey]);
$this->logger->info('User logged in', ['user_id' => $id]);
$this->logger->warning('Slow query', ['ms' => 1200]);
$this->logger->error('Payment failed', ['order' => $id]);
Configure in .env:
LOG_CHANNEL=stderr # or: file
LOG_LEVEL=debug # minimum level to record
LOG_PATH=/var/log/app.log
🌐 HTTP Client
A cURL wrapper with proper error handling, all HTTP verbs, and SSL verification on by default. Errors are never swallowed — they throw and log.
use FastREST\Helpers\HttpClient;
$client = new HttpClient('https://api.example.com', $logger);
$response = $client->get('/users', ['Authorization' => 'Bearer ' . $token]);
$response = $client->post('/orders', ['product_id' => 5, 'qty' => 2]);
$response = $client->put('/products/5', ['price' => 14.99]);
$response = $client->patch('/products/5', ['status' => 'sale']);
$response = $client->delete('/products/5');
// Reading the response
$response->statusCode; // int
$response->isSuccess(); // true for 2xx
$response->json(); // decoded array or null
$response->body; // raw string
$response->getHeader('ETag'); // response header
🔑 OAuth 1.0
use FastREST\Helpers\OauthHelper;
$headers = OauthHelper::buildAuthorizationHeaders(
method: 'POST',
url: 'https://api.example.com/oauth/request_token',
consumerKey: $_ENV['OAUTH_CONSUMER_KEY'],
consumerSecret: $_ENV['OAUTH_CONSUMER_SECRET'],
token: $accessToken, // optional
tokenSecret: $accessTokenSecret, // optional
);
$client = new HttpClient('https://api.example.com', $logger);
$response = $client->post('/resource', $body, $headers);
The nonce uses random_bytes() — cryptographically secure, not str_shuffle.
🔄 Swapping Modules
Every component is bound to a standard interface. Swapping an implementation is a one-file change in config/container.php. Controllers and services never change.
Swap the logger (PSR-3)
// config/container.php
use Psr\Log\LoggerInterface;
use Acme\MyCustomLogger;
return [
LoggerInterface::class => factory(fn() => new MyCustomLogger()),
];
Every class that type-hints LoggerInterface now receives MyCustomLogger.
Swap the DI container (PSR-11)
The framework only calls $container->get(ClassName::class) in the router. To replace PHP-DI with e.g. League Container or Symfony DI:
// public/index.php
$container = (new YourContainerBuilder())->build();
$router = new Router($container); // that's the only change
Swap the PSR-7 HTTP library
The router, middleware, and controllers only type-hint PSR-7 interfaces. To switch from Nyholm to Guzzle PSR-7 or Laminas Diactoros:
- Replace
nyholm/psr7incomposer.json - Update the request factory in
public/index.php - Nothing else changes
Add any PSR-15 middleware package
composer require middlewares/rate-limit
use Middlewares\RateLimit;
$pipeline = new MiddlewarePipeline([
$container->get(RequestLoggerMiddleware::class),
new RateLimit($limiter), // ← drop it in
$container->get(CorsMiddleware::class),
$router,
]);
⚙️ Configuration
# Application
APP_ENV=development # development | production
APP_DEBUG=true
APP_TIMEZONE=UTC
# Database
DB_TYPE=mysql # mysql | pgsql | sqlite
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=your_database
DB_USER=your_user
DB_PASS=your_password
# Logging
LOG_CHANNEL=stderr # stderr | file
LOG_LEVEL=debug
LOG_PATH=/var/log/fastrest/app.log
SetEnv, Nginx fastcgi_param, Docker ENV). The .env file is a convenience for local dev only — it's in .gitignore and should never be committed.
🧪 Testing
Tests use PHPUnit with an in-memory SQLite database — no real database required, no test-environment config needed.
composer test
Testing a controller
use FastREST\Database\Connection;
use FastREST\Database\Database;
use FastREST\Controllers\ProductController;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class ProductControllerTest extends TestCase
{
private ProductController $controller;
protected function setUp(): void
{
$conn = new Connection('sqlite', '', 0, ':memory:', '', '');
$db = new Database($conn, new NullLogger());
$db->query('CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)');
$db->insert('products', ['name' => 'Widget', 'price' => 9.99]);
$this->controller = new ProductController($db, new NullLogger());
}
public function testIndexReturns200(): void
{
$factory = new Psr17Factory();
$request = $factory->createServerRequest('GET', '/products');
$response = $this->controller->index($request);
$this->assertSame(200, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
$this->assertSame('success', $body['status']);
}
}
🚀 Migrating from v1
Controllers
| v1 | v2 |
|---|---|
static function indexAction() | public function index(ServerRequestInterface $r) |
return array('status' => 'success') | return Response::json(['status' => 'success']) |
Database::getInstance() | Constructor-injected $this->db |
Logger::getInstance() | Constructor-injected $this->logger |
| Route auto-detected from URL | Explicit entry in config/routes.php |
Config
| v1 | v2 |
|---|---|
config/config.php with define() | .env file via $_ENV |
| Credentials hard-coded, committed to git | .env excluded from git |
📁 Project Structure
fastrest/
├── config/
│ ├── container.php ← Service wiring (PSR-11 definitions)
│ └── routes.php ← Route table
├── public/
│ └── index.php ← Entry point — only web-accessible dir
├── src/
│ ├── Controllers/ ← Your application controllers
│ ├── Database/
│ │ ├── Connection.php ← PDO factory (env-aware, multi-DB)
│ │ ├── Database.php ← CRUD helper (all bugs fixed)
│ │ └── QueryBuilder.php ← Fluent SELECT builder
│ ├── Exceptions/
│ │ └── HttpException.php
│ ├── Helpers/
│ │ ├── HttpClient.php ← cURL wrapper (all methods)
│ │ ├── HttpResponse.php ← Response value object
│ │ └── OauthHelper.php ← OAuth 1.0 header generator
│ ├── Http/
│ │ ├── MiddlewarePipeline.php
│ │ ├── Response.php ← JSON response factory
│ │ └── Router.php ← HTTP-verb-aware dispatcher
│ └── Middleware/
│ ├── CorsMiddleware.php
│ ├── JsonBodyParserMiddleware.php
│ └── RequestLoggerMiddleware.php
├── tests/
│ └── Unit/
│ ├── Database/
│ │ ├── DatabaseTest.php
│ │ └── QueryBuilderTest.php
│ └── Http/
│ └── RouterTest.php
├── .env.example
├── .gitignore
├── composer.json
├── phpunit.xml
└── README.md