Lift v1.3.0
Real-world use cases

Built with Lift

Six production patterns — from Telegram bots to AI gateways — each showing why Lift fits the job.

Use case 1/6

Telegram Bot

Webhook handler + async job dispatch

Routing Queue PSR-15 middleware

Why Lift fits this

  • PSR-15 middleware verifies Telegram's X-Telegram-Bot-Api-Secret-Token before your code runs
  • Heavy tasks (sending media, calling external APIs) dispatched to a Redis queue — webhook returns 200 instantly
  • Same app runs under RoadRunner for high-throughput bots without restarting PHP per update
telegram-bot.php
<?php
use Lift\App;
use Lift\Http\Request;
use Lift\Http\Response;
use Lift\Queue\RedisQueue;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class TelegramSecretMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $req, RequestHandlerInterface $next): ResponseInterface
    {
        if ($req->getHeaderLine('X-Telegram-Bot-Api-Secret-Token') !== $_ENV['TG_SECRET']) {
            return Response::json(['error' => 'Forbidden'], 403);
        }
        return $next->handle($req);
    }
}

$app = new App();
$app->setQueue(new RedisQueue($app->make(\Lift\Redis\RedisClient::class)));
$app->use(TelegramSecretMiddleware::class);

$app->post('/webhook', function (Request $req) use ($app) {
    $app->dispatch(new HandleTelegramUpdate($req->json()));
    return Response::noContent();
});

$app->run();
Use case 2/6

AI Gateway

Streaming LLM proxy with auth and rate limiting

JWT auth Rate limit SSE stream

Why Lift fits this

  • new SseResponse(fn(SseEmitter $emit) {...}) streams tokens as they arrive — no buffering, no timeout
  • RateLimitMiddleware takes a CacheInterface store — swap ArrayCache for RedisCache in production
  • JWT middleware injects the caller's plan/quota as request attributes — no DB hit per request
ai-gateway.php
<?php
use Lift\Cache\RedisCache;
use Lift\Http\Request;
use Lift\Http\SseResponse;
use Lift\Http\SseEmitter;
use Lift\Http\SseEvent;
use Lift\Middleware\RateLimitMiddleware;
use Lift\Redis\RedisClient;

$app->group('/v1', function ($g) use ($app) {
    $g->middleware(new RateLimitMiddleware(
        store:         new RedisCache($app->make(RedisClient::class)),
        maxRequests:   60,
        windowSeconds: 60,
    ));
    $g->middleware(ApiKeyMiddleware::class);

    $g->post('/chat', function (Request $req) {
        $caller = $req->getAttribute('caller');

        return new SseResponse(function (SseEmitter $emit) use ($req, $caller) {
            $stream = openai_stream(
                model:  $caller->plan->model,
                prompt: $req->input('prompt'),
            );
            foreach ($stream as $token) {
                $emit(SseEvent::json(['token' => $token]));
            }
        });
    });
});
Use case 3/6

SaaS API Backend

REST CRUD, JWT auth, validation, background jobs

Validation JWT DB Queue

Why Lift fits this

  • Controllers are plain classes — no base class, no magic. DI injects repositories automatically
  • $req->validate([rules]) merges body + query + route params and throws ValidationException on failure (auto-422)
  • Migrations + query builder ship with the framework; add your preferred ORM if needed
saas-backend.php
<?php
use Lift\Http\Request;
use Lift\Http\Response;
use Lift\Queue\QueueInterface;

class ProjectController
{
    public function __construct(
        private readonly ProjectRepository $projects,
        private readonly QueueInterface $queue,
    ) {}

    public function store(Request $req): Response
    {
        // validate() merges body+query+route, throws 422 on failure
        $data = $req->validate([
            'name' => 'required|max:80',
            'plan' => 'required|in:free,pro,enterprise',
        ]);

        $project = $this->projects->create(
            $req->getAttribute('user')['sub'],
            $data,
        );

        $this->queue->push(new SendWelcomeEmail($project));

        return Response::json($project, 201);
    }
}

$app->group('/api/v1', function ($g) {
    $g->middleware(AuthMiddleware::class);
    $g->post('/projects', [ProjectController::class, 'store']);
    $g->get('/projects',  [ProjectController::class, 'index']);
});
Use case 4/6

Realtime Data Feed

Server-Sent Events + persistent RoadRunner worker

SSE RoadRunner Redis list

Why Lift fits this

  • Producers lPush events onto a Redis list; the SSE generator drains it with rPop
  • SseEvent::json($data) builds the correct wire format; SseEmitter flushes after each event
  • RoadRunner keeps the worker warm — the Redis connection is reused across streams
realtime-feed.php
<?php
use Lift\App;
use Lift\Http\Request;
use Lift\Http\SseResponse;
use Lift\Http\SseEmitter;
use Lift\Http\SseEvent;
use Lift\Redis\RedisClient;
use Lift\Runtime\RoadRunnerWorker;

$app = new App();

// Producers elsewhere: $redis->lPush("feed:{$channel}", json_encode($event));
$app->get('/events/{channel}', function (Request $req) use ($app) {
    $key   = 'feed:' . $req->param('channel');
    $redis = $app->make(RedisClient::class);

    return new SseResponse(function (SseEmitter $emit) use ($redis, $key) {
        while (true) {
            $event = $redis->rPop($key);
            if ($event === false) {
                usleep(200_000); // nothing new — back off 200ms
                continue;
            }
            $emit(SseEvent::json(json_decode($event, true)));
        }
    });
});

(new RoadRunnerWorker($app))->serve();
Use case 5/6

Webhook Receiver

Fast ingest, HMAC verification, async processing

Signer Queue Routing

Why Lift fits this

  • Signer::verify() uses hash_equals() — constant-time comparison, safe against timing attacks
  • Webhook returns 204 in microseconds; actual processing happens in a queue worker
  • Route groups let you namespace providers: /webhooks/stripe, /webhooks/github, etc.
webhook-receiver.php
<?php
use Lift\Crypto\Signer;
use Lift\Http\Request;
use Lift\Http\Response;

$app->group('/webhooks', function ($g) use ($app) {

    $g->post('/stripe', function (Request $req) use ($app) {
        $signer = new Signer($_ENV['STRIPE_WEBHOOK_SECRET']);
        if (!$signer->verify((string) $req->getBody(), $req->getHeaderLine('Stripe-Signature'))) {
            return Response::json(['error' => 'Invalid signature'], 400);
        }

        $app->dispatch(new ProcessStripeEvent($req->json()));
        return Response::noContent();
    });

    $g->post('/github', function (Request $req) use ($app) {
        $signer = new Signer($_ENV['GITHUB_WEBHOOK_SECRET']);
        // GitHub sends "sha256=<hex>", strip the prefix
        $sig = substr($req->getHeaderLine('X-Hub-Signature-256'), 7);
        if (!$signer->verify((string) $req->getBody(), $sig)) {
            return Response::json(['error' => 'Invalid signature'], 400);
        }

        $app->dispatch(new ProcessGithubEvent($req->json()));
        return Response::noContent();
    });
});
Use case 6/6

Auth Server

JWT issuance, refresh tokens, rate-limited login

JWT Encrypter Rate limit

Why Lift fits this

  • Jwt::encode($payload) — exp is a standard claim in the payload array, no separate ttl parameter
  • Encrypter (AES-256-GCM) encrypts the refresh token payload — tamper-proof without a DB lookup
  • RateLimitMiddleware on the route blocks brute-force before the handler runs
auth-server.php
<?php
use Lift\Cache\RedisCache;
use Lift\Crypto\Encrypter;
use Lift\Http\Request;
use Lift\Http\Response;
use Lift\Jwt\Jwt;
use Lift\Middleware\RateLimitMiddleware;
use Lift\Redis\RedisClient;

// Encrypter needs a 32-byte key, Jwt needs a secret — register both
$app->singleton(Jwt::class, fn() => new Jwt(secret: $_ENV['JWT_SECRET']));
$app->singleton(Encrypter::class, fn() => new Encrypter(base64_decode($_ENV['APP_KEY'])));

$app->post('/auth/login', function (Request $req) use ($app) {
    $data = $req->validate([
        'email'    => 'required|email',
        'password' => 'required',
    ]);

    $user = UserRepository::findByEmail($data['email']);
    if (!$user || !password_verify($data['password'], $user->password_hash)) {
        return Response::json(['error' => 'Invalid credentials'], 401);
    }

    $jwt       = $app->make(Jwt::class);
    $encrypter = $app->make(Encrypter::class);

    // exp goes inside the payload — no separate ttl param
    $accessToken = $jwt->encode([
        'sub'  => $user->id,
        'role' => $user->role,
        'exp'  => time() + 900,
    ]);

    $refreshToken = $encrypter->encrypt(json_encode([
        'uid' => $user->id,
        'exp' => time() + 2_592_000,
    ]));

    return Response::json([
        'access_token'  => $accessToken,
        'refresh_token' => $refreshToken,
        'expires_in'    => 900,
    ]);
})->middleware(new RateLimitMiddleware(
    store:         new RedisCache($app->make(RedisClient::class)),
    maxRequests:   5,
    windowSeconds: 60,
));

Start building

All patterns above work out of the box. No extra packages for JWT, crypto, SSE, queues, or rate limiting.