Quick Start
By the end of this page you will have built a small JSON API with several routes, parameter validation, dependency injection, and a controller class — using nothing but what ships with Lift.
We'll start with a literal one-liner and slowly grow it into something you'd recognise from a real service.
Stage 0 — Hello, World
If you finished Installation, public/index.php already looks like this:
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Lift\App;
use Lift\Http\Response;
$app = new App();
$app->get('/', fn() => Response::json(['message' => 'Hello, World!']));
$app->run();
Run it:
php -S 127.0.0.1:8000 -t public
curl http://127.0.0.1:8000/
# {"message":"Hello, World!"}
Three things to notice:
new App()— no factory, no builder. The app is just an object.$app->get('/', $handler)—$handlercan be anything callable: a closure,[Class::class, 'method'], or an invokable class name.Response::json([...])is one of several factory shortcuts. We'll cover all of them in Response.
If your handler returns an
array, Lift auto-wraps it inResponse::json(...). Sofn() => ['ok' => true]works too — see auto-response conversion. For the rest of the tutorial we'll be explicit and useResponse::json(...).
Stage 1 — Multiple routes
$app->get('/', fn() => Response::json(['message' => 'Hello, World!']));
$app->get('/health', fn() => Response::json(['ok' => true]));
$app->get('/version', fn() => Response::json(['version' => '1.0.0']));
// POST / PUT / PATCH / DELETE all work the same way
$app->post('/echo', function (\Lift\Http\Request $req) {
return Response::json(['you_sent' => $req->json()]);
});
Quick test:
curl -X POST -H 'Content-Type: application/json' \
-d '{"foo":"bar"}' \
http://127.0.0.1:8000/echo
# {"you_sent":{"foo":"bar"}}
Notice the closure parameter \Lift\Http\Request $req. Lift automatically injects the current request into any handler that asks for one. You don't have to pass it manually.
Stage 2 — Route parameters
Anything inside {...} becomes a parameter you can read with $req->param(...):
$app->get('/users/{id}', function (\Lift\Http\Request $req) {
return Response::json([
'id' => $req->param('id'),
]);
});
curl http://127.0.0.1:8000/users/42
# {"id":"42"}
Note that id arrives as a string — that's what HTTP gives you. Cast it yourself:
$id = (int) $req->param('id');
You can also constrain a parameter with a regex pattern. The colon separates the name from the pattern:
$app->get('/posts/{id:\d+}', $handler); // digits only
$app->get('/articles/{slug:[a-z0-9-]+}', $handler); // lowercase + dashes
If the URL doesn't match the pattern, Lift returns 404 — the handler is never called.
Stage 3 — A tiny in-memory REST API
Let's build something close to a real CRUD endpoint, using just a PHP array as the "database":
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Lift\App;
use Lift\Http\Request;
use Lift\Http\Response;
$app = new App();
/** @var array<int, array{id:int, name:string}> $users */
$users = [
1 => ['id' => 1, 'name' => 'Alice'],
2 => ['id' => 2, 'name' => 'Bob'],
];
$nextId = 3;
// List
$app->get('/users', fn() => Response::json(array_values($users)));
// Show
$app->get('/users/{id:\d+}', function (Request $req) use (&$users) {
$id = (int) $req->param('id');
if (!isset($users[$id])) {
return Response::json(['error' => 'User not found'], 404);
}
return Response::json($users[$id]);
});
// Create
$app->post('/users', function (Request $req) use (&$users, &$nextId) {
$name = $req->json()['name'] ?? null;
if (!is_string($name) || $name === '') {
return Response::json(['error' => 'name is required'], 422);
}
$id = $nextId++;
$users[$id] = ['id' => $id, 'name' => $name];
return Response::json($users[$id], 201);
});
// Update
$app->put('/users/{id:\d+}', function (Request $req) use (&$users) {
$id = (int) $req->param('id');
if (!isset($users[$id])) {
return Response::json(['error' => 'User not found'], 404);
}
$users[$id]['name'] = $req->json()['name'] ?? $users[$id]['name'];
return Response::json($users[$id]);
});
// Delete
$app->delete('/users/{id:\d+}', function (Request $req) use (&$users) {
unset($users[(int) $req->param('id')]);
return Response::noContent(); // 204
});
$app->run();
Try it:
curl http://127.0.0.1:8000/users
curl http://127.0.0.1:8000/users/1
curl -X POST -H 'Content-Type: application/json' -d '{"name":"Carol"}' http://127.0.0.1:8000/users
curl -X PUT -H 'Content-Type: application/json' -d '{"name":"Bobby"}' http://127.0.0.1:8000/users/2
curl -X DELETE http://127.0.0.1:8000/users/1
Things you may have missed:
$req->json()returns the decoded JSON body as an associative array. Always.Response::json($data, 201)lets you set a custom status code.Response::noContent()is a shortcut for HTTP 204.Response::json(['error' => ...], 422)is the conventional shape for validation errors.
Stage 4 — Validation, the easy way
Hand-rolling if (!$name) checks gets old fast. Lift has a validator. The shortest way to use it is $req->validate([...]):
use Lift\Validation\ValidationException;
$app->post('/users', function (Request $req) use (&$users, &$nextId) {
try {
$data = $req->validate([
'name' => 'required|string|min:2|max:255',
'email' => 'required|email',
]);
} catch (ValidationException $e) {
return Response::json(['errors' => $e->errors()], 422);
}
$id = $nextId++;
$users[$id] = ['id' => $id, ...$data];
return Response::json($users[$id], 201);
});
You don't actually need the try/catch: if a ValidationException escapes a handler, Lift's default error handler converts it to a 422 with the same errors shape. The full rule list lives in Validation.
Stage 5 — Controllers and dependency injection
Closures in index.php are fine for 10 routes. Past that, you'll want classes. Lift's container will instantiate them and inject their dependencies automatically.
my-app/
├── public/index.php
└── src/
├── UserRepository.php
└── UserController.php
src/UserRepository.php:
<?php
namespace App;
final class UserRepository
{
/** @var array<int, array{id:int, name:string}> */
private array $users = [
1 => ['id' => 1, 'name' => 'Alice'],
2 => ['id' => 2, 'name' => 'Bob'],
];
private int $nextId = 3;
public function all(): array { return array_values($this->users); }
public function find(int $id): ?array { return $this->users[$id] ?? null; }
public function create(array $data): array
{
$id = $this->nextId++;
return $this->users[$id] = ['id' => $id, ...$data];
}
}
src/UserController.php:
<?php
namespace App;
use Lift\Http\Request;
use Lift\Http\Response;
final class UserController
{
// 👇 Lift autowires this via the container — you never construct UserController yourself.
public function __construct(private readonly UserRepository $users) {}
public function index(): array
{
return $this->users->all();
}
public function show(Request $req): Response
{
$user = $this->users->find((int) $req->param('id'));
return $user
? Response::json($user)
: Response::json(['error' => 'Not found'], 404);
}
public function store(Request $req): Response
{
$data = $req->validate([
'name' => 'required|string|min:2',
'email' => 'required|email',
]);
return Response::json($this->users->create($data), 201);
}
}
Tell Composer about the namespace — add this to composer.json and run composer dump-autoload:
"autoload": {
"psr-4": { "App\\": "src/" }
}
Finally, wire the routes in public/index.php:
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Lift\App;
use App\UserController;
use App\UserRepository;
$app = new App();
// Cache the repository for the lifetime of the request
$app->singleton(UserRepository::class);
$app->get ('/users', [UserController::class, 'index']);
$app->get ('/users/{id:\d+}', [UserController::class, 'show']);
$app->post ('/users', [UserController::class, 'store']);
$app->run();
That's it. Notice that:
- You never explicitly construct
UserControllerorUserRepository— the container does it via autowiring. $app->singleton(UserRepository::class)tells the container "make one, reuse it". Without that line, you'd get a fresh repo per call (and lose your in-memory data).[UserController::class, 'index']is PHP's standard[$class, $method]callable. Lift resolves the class through the container, then calls the method.
Stage 6 — Grouping routes
Most APIs version their endpoints under /api/v1/.... Groups handle the prefix for you:
$app->group('/api/v1', function ($group) {
$group->get ('/users', [UserController::class, 'index']);
$group->get ('/users/{id:\d+}', [UserController::class, 'show']);
$group->post ('/users', [UserController::class, 'store']);
});
Groups also accept middleware, named-route prefixes, and can be nested. See Routing.
Stage 7 — Adding middleware
Say you want every response to carry a X-Request-Id header. Middleware is the right place.
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class RequestIdMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $req, RequestHandlerInterface $next): ResponseInterface
{
$id = $req->getHeaderLine('X-Request-Id') ?: bin2hex(random_bytes(8));
return $next->handle($req->withAttribute('request_id', $id))
->withHeader('X-Request-Id', $id);
}
}
$app->use(RequestIdMiddleware::class); // global — runs on every request
Routes can also have per-route or per-group middleware. See Middleware.
Stage 8 — Wiring real persistence
Swap the in-memory array for SQLite (or MySQL/Postgres) in under 20 lines:
use Lift\Database\Connection;
$app->singleton(Connection::class, fn() => Connection::fromConfig([
'driver' => 'sqlite',
'database' => __DIR__ . '/../database.sqlite',
]));
// In your repository:
public function __construct(private readonly Connection $db) {}
public function all(): array
{
return $this->db->table('users')->orderBy('id')->get();
}
Full walkthrough including migrations: Database.
Where to go next
You now know enough to build a real CRUD service. The natural next steps:
| If you want to… | Read |
|---|---|
| Understand every routing feature | Routing |
| Read input from forms, JSON, files | Request |
| Send HTML, set cookies, redirect | Response |
| Wire services without globals | DI Container |
| Add auth, CORS, rate limiting | Middleware, Security |
| Validate input properly | Validation |
| Talk to a database | Database |
| Process background jobs | Queues |
| Write tests | Testing |
| Deploy to production | Installation §6 |