nimbly / limber
A super minimal HTTP framework that doesn't get in your way.
Installs: 8 199
Dependents: 0
Suggesters: 0
Security: 0
Stars: 15
Watchers: 6
Forks: 1
Open Issues: 0
Requires
- php: ^8.2
- nimbly/resolve: ^2.0
- psr/container: ^1.0||^2.0
- psr/http-message: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- ext-xdebug: *
- fakerphp/faker: ^1.20
- nimbly/capsule: ^2.0
- nimbly/carton: ^2.0
- phpunit/phpunit: ^9.0
- symfony/var-dumper: ^4.2
- vimeo/psalm: ^5.0
This package is auto-updated.
Last update: 2024-10-27 15:28:36 UTC
README
A super minimal PSR-7, 15, and 11 compliant HTTP framework that doesn't get in your way.
Limber is intended for advanced users who are comfortable setting up their own framework and pulling in packages best suited for their particular use case.
Limber includes
- A router
- PSR-7 HTTP message compliant
- PSR-11 container compliant
- PSR-15 middleware compliant
- A thin
Application
layer to tie everything together
Requirements
- PHP 8.2+
- PSR-7 HTTP Message library
Installation
composer require nimbly/limber
Quick start
Install PSR-7 library
Limber does not ship with a PSR-7 implementation which is required to receive HTTP requests and send back responses. Let's pull one into our project.
composer require nimbly/capsule
Sample application
-
Create your entrypoint (or front controller), for example
index.php
, and start by creating a newRouter
instance and attaching your routes to it. -
Once your routes have been defined, you can create the
Application
instance and pass the router in to it. -
You can then
dispatch
requests through the application and receive a response back. -
And finally, you can
send
a response back to the calling client.
<?php require __DIR__ . "/vendor/autoload.php"; // Create a Router instance and define a route. $router = new Nimbly\Limber\Router\Router; $router->get("/", fn() => new Nimbly\Capsule\Response(200, "Hello World!")); // Create Application instance with router. $application = new Nimbly\Limber\Application($router); // Dispatch a PSR-7 ServerRequestInterface instance and get back a PSR-7 ResponseInterface instance $response = $application->dispatch( Nimbly\Capsule\Factory\ServerRequestFactory::createFromGlobals() ); // Send the ResponseInterface instance $application->send($response);
Advanced configuration
A note on autowiring support
Limber will invoke your route handlers using reflection based autowiring. The ServerRequestInterface
instance, URI path parameters defined in the route, and request attributes will be automatically resolved for you, without the need of a PSR-11 container.
However, any domain specific services and classes that are required in your handlers, should be defined in a PSR-11 container instance.
Adding PSR-11 container support
Limber is able to autowire your request handlers and middleware with the aid of a PSR-11 container instance. However, Limber does not ship with a PSR-11 Container implementation, so you will need to bring your own if you require one. Here are some options:
Let's add container support to our application.
composer require nimbly/carton
And update our entry point by passing the container instance into the Application
constructor.
<?php // Create PSR-11 container instance and configure as needed. $container = new Container; $container->set( Foo:class, fn(): Foo => new Foo(\getenv("FOO_NAME")) ); // Create Application instance with router and container. $application = new Nimbly\Limber\Application( router: $router, container: $container );
Middleware
Limber supports PSR-15 middleware. All middleware must implement Psr\Http\Server\MiddlewareInterface
.
You can pass middleware as one or more of the following types:
- An instance of
MiddlewareInterface
- A
class-string
that implementsMiddlewareInterface
- A
class-string
that implementsMiddlewareInterface
as an index and an array of key=>value pairs as parameters to be used in dependency injection when autowiring.
Any class-string
types will be autowired using the Container
instance (if any) for dependency injection.
If auto wiring fails, a DependencyResolutionException
exception will be thrown.
class SampleMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { // Add a custom header to the request before sending to route handler $request = $request->withAddedHeader("X-Foo", "Bar"); $response = $handler->handle($request); // Add a custom header to the response before sending back to client return $response->withAddedHeader("X-Custom-Header", "Foo"); } }
Now let's add this global middleware layer to the Limber application instance.
$application = new Nimbly\Limber\Application( router: $router, container: $container, middleware: [ App\Http\Middleware\SampleMiddleware::class ] );
HTTP Exceptions
Limber has most major HTTP error response codes (4xx and 5xx response codes) mapped to exceptions that extend the Nimbly\Limber\Exceptions\HttpException
abstract. For example Nimbly\Limber\Exceptions\NotFoundHttpException
(404 Not Found). These exceptions have methods to get the HTTP response status code and as well as any response headers that may be required for that response code.
Coupled with a default exception handler (see next section), you can create a single source for crafting HTTP error responses.
Exception handling
You can set a custom default exception handler that will process any exception thrown within the middleware chain.
The exception handler must implement Nimbly\Limber\ExceptionHandlerInterface
.
NOTE Exceptions thrown outside of the middleware chain (e.g. during bootstrap process) will continue to bubble up unless caught elsewhere.
namespace App\Http; use Nimbly\Limber\ExceptionHandlerInterface; use Nimbly\Limber\Exceptions\HttpException; class ExceptionHandler implements ExceptionHandlerInterface { public function handle(Throwable $exception, ServerRequestInterface $request): ResponseInterface { $status_code = $exception instanceof HttpException ? $exception->getHttpStatus() : 500; $response_headers = $exception instanceof HttpException ? $exception->getHeaders() : []; return new Response( $status_code, \json_encode([ "error" => [ "code" => $exception->getCode(), "message" => $exception->getMessage() ] ]), \array_merge( $response_headers, [ "Content-Type" => "application/json" ] ) ); } }
Now let's add the exception handler to the Limber application instance.
$application = new Nimbly\Limber\Application( router: $router, container: $container, middleware: [ App\Http\Middleware\FooMiddlware::class ], exceptionHandler: new App\Http\ExceptionHandler );
Router
The Router
builds and collects Route
instances and provides helper methods to group Routes
together sharing a common configuration (path prefix, namespace, middleware, etc).
Defining routes
Create a Router
instance and begin defining your routes. There are convenience methods for all major HTTP verbs (get, post, put, patch, and delete).
$router = new Nimbly\Limber\Router\Router; $router->get("/fruits", "FruitsHandler@all"); $router->post("/fruits", "FruitsHandler@create"); $router->patch("/fruits/{id}", "FruitsHandler@update"); $router->delete("/fruits/{id}", "FruitsHandler@delete");
A route can respond to any number of HTTP methods by using the add
method and passing an array of methods as strings.
$router->add(["get", "post"], "/fruits", "FruitsHandler@create");
HEAD requests
By default, Limber will add a HEAD
method to each GET
route.
Route paths
Paths can be static or contain named parameters. Named parameters will be injected into your route handler if the handler also contains a parameter of the same name.
$router->get("/books/{isbn}", "BooksHandler@findByIsbn");
In the following handler, both the $request
and $isbn
parameters will be injected automatically.
class BooksHandler { public function getByIsbn(ServerRequestInterface $request, string $isbn): ResponseInterface { $book = BookModel::findByIsbn($isbn); if( empty($book) ){ throw new NotFoundHttpException("ISBN not found."); } return new JsonResponse( 200, $book->toArray() ); } }
Route path patterns
Your named parameters can also enforce a specific regular expression pattern when being matched - just add the pattern after the placeholder name with a colon.
Limber has several predefined path patterns you can use:
alpha
Alphabetic characters only (A-Z and a-z), of any lengthint
Integer number of any lengthalphanumeric
Any combination of number or alphabetic characteruuid
A Universally Unique Identifier or sometimes known as a GUID.hex
A hexidecimal value, of any length
// Get a book by its ID and match the ID to a UUID. $router->get("/books/{id:uuid}", "BooksHandler@get");
You can define your own patterns to match using the Router::setPattern()
static method.
Router::setPattern("isbn", "\d{9}[\d|X]"); $router = new Router; $router->get("/books/{id:isbn}", "BooksHandler@getByIsbn");
Route handlers
Route handlers may either be a callable
or a string in the format Fully\Qualified\Namespace\ClassName@Method (for example App\Handlers\v1\BooksHandler@create
).
Route handlers must return a ResponseInterface
instance.
Limber uses reflection based autowiring to automatically resolve your route handlers including constructor and function/method parameters. The ServerRequestInterface
instance, path parameters, and any attributes attached to the ServerRequestInterface
instance will be resolved and injected for you. This applies for both closure based handlers as well as Class@Method based handlers.
You may also optionally supply a PSR-11 compliant ContainerInterface
instance to aid in route handler parameter resolution. By doing this, you can easily have your application specific dependencies resolved and injected into your handlers by Limber. See PSR-11 Container support section for more information.
// Closure based handler $router->get( "/books/{id:isbn}", function(ServerRequestInterface $request, string $id): ResponseInterface { $book = Books::find($id); if( empty($book) ){ throw new NotFoundHttpException("Book not found."); } return new Response(200, \json_encode($book)); } ); // String references to ClassName@Method $router->patch("/books/{id:isbn}", "App\Handlers\BooksHandler@update"); // If a ContainerInterface instance was assigned to the application and contains an InventoryService instance, it will be injected into this handler. $router->post( "/books", function(ServerRequestInterface $request, InventoryService $inventoryService): ResponseInterface { $book = Book::make($request->getParsedBody()); $inventoryService->add($book); return new Response(201, \json_encode($book)); } );
Route configuration
You can configure individual routes to respond to a specific scheme, a specific hostname, process additional middleware, or pass along attributes to the ServerRequestInterface
instance.
Scheme
$router->post( path: "books", handler: "\App\Http\Handlers\BooksHandler@create", scheme: "https" );
Route specific middleware
$router->post( path: "books", handler: "\App\Http\Handlers\BooksHandler@create", middleware: [new FooMiddleware] );
Hostname
$router->post( path: "books", handler: "\App\Http\Handlers\BooksHandler@create", hostnames: ["example.org"] );
Attributes
$router->post( path: "books", handler: "\App\Http\Handlers\BooksHandler@create", attributes: [ "Attribute1" => "Value1" ] );
Route groups
You can group routes together using the group
method and all routes contained will inherit the configuration you have defined.
scheme
(optional) string The HTTP scheme (http
orhttps
) to match against. Anull
value will match against any value.middleware
(optional) array<string> or array<MiddlewareInterface> or array<callable> An array of all middleware classes (fully qualified namespace) or actual instances of middleware.prefix
(optional) string A string prepended to all URIs when matching the request.namespace
(optional) string A string prepended to all string based handlers before instantiating a new class.hostnames
(optional) array<string> An array of hostnames to be matched against.attributes
(optional) array<string,mixed> An array of key=>value pairs representing attributes that will be attached to theServerRequestInterface
instance if the route matches.routes
(required) callable A callable that accepts theRouter
instance where you can add additional routes within the group.
$router->group( hostnames: ["sub.domain.com"], middleware: [ FooMiddleware::class, BarMiddleware::class ], namespace: "\App\Sub.Domain\Handlers", prefix: "v1", routes: function(Router $router): void { $router->get("books/{isbn}", "BooksHandler@getByIsbn"); $router->post("books", "BooksHandler@create"); } );
Groups can be nested and will inherit their parent group's settings unless the setting is overridden. Middleware settings however are merged with their parent's settings.
$router->group( hostnames: ["sub.domain.com"], middleware: [ FooMiddleware::class, BarMiddleware::class ], namespace: "\App\Sub.Domain\Handlers", prefix: "v1", routes: function(Router $router): void { $router->get("books/{isbn}", "BooksHandler@getByIsbn"); $router->post("books", "BooksHandler@create"); // This group will inherit all group settings from the parent group, override // the namespace property, and will merge in an additional middleware (AdminMiddleware). $router->group( namespace: "\App\Sub.Domain\Handlers\Admin", middleware: [ AdminMiddleware::class ], routes: function(Router $router): void { $route->delete("books/{isbn}", "BooksHandler@deleteBook"); } ); } );
Using with React/Http
Because Limber is PSR-7 compliant, it works very well with react/http to create a standalone HTTP service without the need for an additional HTTP server (nginx, Apache, etc) - great for containerizing your service with minimal dependencies.
Install React/Http
composer install react/http
Create entry point
Create a file called main.php
(or whatever you want) to be the container's command/entry point.
<?php use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Nimbly\Capsule\Response; // Create the router and some routes. $router = new Nimbly\Limber\Router; $router->get("/", function(ServerRequestInterface $request): ResponseInterface { return new Response( "Hello world!" ); }); // Create the Limber Application instance. $application = new Nimbly\Limber\Application($router); // Create the HTTP server to handle incoming HTTP requests with your Limber Application instance. $httpServer = new React\Http\HttpServer( function(ServerRequestInterface $request) use ($application): ResponseInterface { return $application->dispatch($request); } ); // Listen on port 8000. $httpServer->listen( new React\Socket\SocketServer("0.0.0.0:8000"); );
Adding process signal handlers
React/Http supports tapping into processing signals, commonly used by container orchestration systems to shutdown processes. You can use these interrupts to signal to React/Http to stop the event loop. This functionality requires the PHP pcntl
module be installed. (See next section.)
$loop = React\EventLoop\Loop::get(); $loop->addSignal( SIGINT, function(int $signal) use ($loop): void { \error_log("SIGINT received: Shutting down gracefully."); $loop->stop(); } );
Create Dockerfile
Create a Dockerfile
in the root of your application.
We'll extend from the official PHP 8.2 docker image and add some useful tools like composer
, a better event loop library from PECL, and install support for process control (pcntl
). Process control will allow your service to shutdown gracefully when a SIGINT
or SIGHUP
signal is received.
Obviously, edit this file to match your specific needs.
FROM php:8.2-cli
RUN apt-get update && apt-get upgrade --yes
RUN curl --silent --show-error https://getcomposer.org/installer | php && \
mv composer.phar /usr/bin/composer
RUN mkdir -p /usr/src/php/ext && curl --silent https://pecl.php.net/get/ev-1.1.5.tgz | tar xvzf - -C /usr/src/php/ext
# Add other PHP modules
RUN docker-php-ext-install pcntl ev-1.1.5
WORKDIR /opt/service
ADD . .
RUN composer install --no-dev
CMD [ "php", "main.php" ]
Build docker image
docker image build -t my-service:latest .
Run as container
docker container run -p 8000:8000 --env-file=.env my-service:latest