serwisant / serwisant-silex
Minimal Silex-compatible micro-framework built on Symfony components and Pimple
Requires
- php: >=8.1
- pimple/pimple: ^3.6
- symfony/asset: ^6.4 || ^7.4 || ^8.0
- symfony/error-handler: ^6.4 || ^7.4 || ^8.0
- symfony/event-dispatcher: ^6.4 || ^7.4 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.4 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.4 || ^8.0
- symfony/routing: ^6.4 || ^7.4 || ^8.0
- symfony/twig-bridge: ^6.4 || ^7.4 || ^8.0
- twig/twig: ^3.21
Requires (Dev)
None
Suggests
- symfony/mime: Content type guessing in Application::sendFile()
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-24 09:02:22 UTC
README
A minimal replacement for Silex 2 (abandoned in 2018), built on current Symfony components (HttpKernel, Routing, HttpFoundation, EventDispatcher, ErrorHandler, Twig Bridge) and Pimple.
It preserves the Silex API to the extent used in Serwisant projects, so migration usually boils down to replacing
use Silex; with use Serwisant\Silex;.
Requirements: PHP 8.1+, Symfony 6.4 / 7.4 / 8.x, Twig 3.21+, Pimple 3.6+.
Table of contents
- Installation
- Quick start
- Application configuration
- Routing
- Controllers
- Grouping routes –
mount() - Middleware
- Error handling
- View handlers
- Responses – helper methods
- Services and providers
- Built-in providers
- Custom providers
- Testing
- Migrating from Silex 2
Installation
composer require serwisant/serwisant-silex
To work on the package code locally, you can use a path repository (e.g. in ~/.composer/config.json):
{
"repositories": [
{"type": "path", "url": "/path/to/serwisant-silex"}
]
}
Quick start
<?php require __DIR__ . '/../vendor/autoload.php'; use Serwisant\Silex; use Symfony\Component\HttpFoundation\Request; $app = new Silex\Application(['debug' => true]); $app->register(new Silex\Provider\TwigServiceProvider(), ['twig.path' => __DIR__ . '/../views']); $app->get('/', function (Silex\Application $app) { return $app->render('index.html.twig'); })->bind('home'); $app->get('/hello/{name}', function (Silex\Application $app, Request $request, $name) { return 'Hello ' . $app->escape($name); }); $app->run();
Application configuration
Serwisant\Silex\Application is a Pimple container, so parameters and services
are set like array entries: in the constructor or later via $app['key'] = value.
| Parameter | Default | Description |
|---|---|---|
debug |
false |
Error page with a stack trace, strict_variables and dump() in Twig |
charset |
UTF-8 |
Encoding of responses, Twig and escape() |
env |
– | Optional environment name, available in Twig as global.environment |
$app = new Silex\Application(['debug' => true, 'env' => 'development']); $app['my.option'] = 42;
Routing
The get(), post(), put(), patch(), delete(), options() methods register a route for the given HTTP method,
match() – for any method. Each returns a Serwisant\Silex\Controller object with a fluent API:
| Method | Description |
|---|---|
bind('name') |
Route name, used by path() / url() |
assert('variable', 'regexp') |
Requirement for a route variable, e.g. '\d+'. When it does not match, 404 is returned |
value('variable', $default) |
Default value – the variable becomes optional |
convert('variable', $callback) |
Converter callback($value, Request), e.g. casting to int or loading an entity |
method('GET|POST') |
Allowed HTTP methods (for match()) |
host('{sub}.example.com') |
Required host |
requireHttp() / requireHttps() |
Required scheme |
before($cb) / after($cb) |
Route middleware, see Middleware |
$app->get('/agreements/{id}', function ($id) { return "Agreement no. $id"; }) ->assert('id', '\d+') ->convert('id', fn($id) => (int)$id) ->bind('agreement'); $app->get('/blog/{page}', fn($page) => "Page $page")->value('page', 1); $app->match('/contact', fn(Request $request) => $request->getMethod())->method('GET|POST');
- Routes without
bind()(or withbind('')) get a name generated from the method and path, e.g.POST_sg_contact. - A
GETroute also handlesHEAD. - A request with a method the route does not support ends with 405 and an
Allowheader. - URLs are generated with
$app->path('agreement', ['id' => 5])and$app->url(...)(absolute URL), and in Twig withpath()andurl().
Controllers
A controller can be:
- a closure – the most common case,
'Class::method'– the class is instantiated without arguments,'service:method'– a method of a service from the container, requires ServiceControllerServiceProvider,- any other
callable.
Controller arguments are injected:
- by type:
Application(or a subclass),Request,SessionInterface(fromSessionServiceProvider), - by name: route variables (
{id}→$id) and request attributes, - from parameter default values.
The order of arguments is arbitrary.
A controller may return:
- a
Response(includingJsonResponse,RedirectResponse,StreamedResponse...), - a string or a
Stringableobject – it will be converted to aResponsewith status 200, - any other value (e.g. an array) – in which case a view handler must handle it.
Grouping routes – mount()
Routes can be grouped under a common prefix. mount() accepts a ControllerCollection, a callable or an
Api\ControllerProviderInterface object:
// 1. callable – receives an empty collection $app->mount('/api', function (Silex\ControllerCollection $api) { $api->get('/items', fn() => ['a' => 1]); // GET /api/items $api->before(function (Request $request) { // applies to all routes under /api // e.g. token check }); }); // 2. collection from the factory $admin = $app['controllers_factory']; $admin->get('/', fn() => 'panel')->bind('admin'); // GET /admin/ $admin->requireHttps(); $app->mount('/admin', $admin); // 3. controller provider – convenient for extracting a module into a separate class class BlogControllerProvider implements Silex\Api\ControllerProviderInterface { public function connect(Silex\Application $app): Silex\ControllerCollection { $blog = $app['controllers_factory']; $blog->get('/', fn() => 'list of posts')->bind('blog'); $blog->get('/{id}', fn($id) => "post $id"); $blog->assert('id', '\d+'); return $blog; } } $app->mount('/blog', new BlogControllerProvider());
Settings called on a collection (assert(), value(), convert(), method(), host(), requireHttp(),
requireHttps(), before(), after()) apply to all of its routes – including those added later. Collections can be
nested via $collection->mount('/v2', $otherCollection).
Middleware
Application middleware
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; // before the controller; returning a Response skips the controller $app->before(function (Request $request, Silex\Application $app) { if (!$request->cookies->has('token')) { return $app->redirect('/login'); } }); // after the controller; the response can be modified or a new one returned $app->after(function (Request $request, Response $response, Silex\Application $app) { $response->headers->set('X-Frame-Options', 'DENY'); }); // after the response has been sent to the client (e.g. logging, sending e-mails) $app->finish(function (Request $request, Response $response, Silex\Application $app) { });
Application middleware only runs for the main request (not for sub-requests). The order can be changed with a
priority: $app->before($cb, Silex\Application::EARLY_EVENT) or Silex\Application::LATE_EVENT.
Route middleware
$app->get('/account', fn() => 'my account') ->before(function (Request $request, Silex\Application $app) { // returning a Response skips the controller }) ->after(function (Request $request, Response $response, Silex\Application $app) { });
Call order
$app->before()– application middleware->before()– route middleware- controller
- view handlers (if the controller did not return a
Response) ->after()– route middleware$app->after()– application middleware- sending the response
$app->finish()
Error handling
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; // only for 404 – the type of the first argument filters exceptions $app->error(function (NotFoundHttpException $e, Request $request, int $code) use ($app) { return new Response($app->renderView('404.html.twig'), 404); }); // all other errors, including PHP errors (TypeError etc.) $app->error(function (\Throwable $e, Request $request, int $code) { if ($code >= 500) { \Sentry\captureException($e); } // no return – handling is passed on to the next handler or the default error page }); // abort handling with an HTTP status code $app->abort(404, 'Not found');
$codeis the HTTP status code: taken from anHttpException(e.g. 404, 405, 403) or 500 for other errors.- A handler returning a
Responseor a string ends error handling. A returned string goes into a response with status$code. - A handler returning
nullpasses the error on. Handlers are called in registration order, and the order can be changed with a priority:$app->error($cb, $priority), default -8. - When no handler returns a response, the Symfony error page (
HtmlErrorRenderer) is displayed. Indebugmode it includes a stack trace, otherwise it is a generic page without details.
View handlers
They allow a controller to return data instead of a Response. The type of the first argument determines which
results the handler processes:
// arrays as JSON $app->view(function (array $result, Request $request) use ($app) { return $app->json($result); }); // date objects as text $app->view(function (\DateTimeInterface $date) { return $date->format('Y-m-d'); }); $app->get('/api/status', fn() => ['status' => 'ok']);
A handler returning a Response ends processing. Any other returned value goes to the next handler (e.g. object →
array → JSON), and a string is converted to a Response.
Responses – helper methods
| Method | Returns |
|---|---|
$app->redirect($url, $status = 302) |
RedirectResponse |
$app->json($data, $status = 200, $headers = []) |
JsonResponse |
$app->stream($callback, $status = 200, $headers = []) |
StreamedResponse – the callback outputs content via echo |
$app->sendFile($file, $status, $headers, $disposition) |
BinaryFileResponse; $disposition e.g. ResponseHeaderBag::DISPOSITION_ATTACHMENT |
$app->render($template, $parameters) |
Response with the rendered Twig template |
$app->renderView($template, $parameters) |
string with the rendered template |
$app->path($route, $parameters) / $app->url(...) |
path / absolute URL of the route |
$app->escape($text) |
text safe to insert into HTML |
$app->abort($code, $message) |
throws HttpException |
sendFile() detects the file's MIME type if the symfony/mime package is installed. Without it,
application/octet-stream is sent, unless the Content-Type header is given explicitly.
Services and providers
Services are defined as in Pimple:
// shared (single instance) $app['mailer'] = function ($app) { return new Mailer($app['mailer.dsn']); }; // new instance on every access $app['form'] = $app->factory(fn($app) => new Form()); // closure as a value (not a factory) $app['slugify'] = $app->protect(fn(string $s) => strtolower($s)); // extending an existing service $app->extend('twig', function (\Twig\Environment $twig, $app) { $twig->addFilter(new \Twig\TwigFilter('upper', 'strtoupper')); return $twig; });
A provider is registered with $app->register(new Provider(), ['parameter' => 'value']). Parameters passed in the
second argument override the defaults set by the provider.
Services always available
| Key | Description |
|---|---|
dispatcher |
EventDispatcher, a listener can also be added via $app->on($event, $cb) |
kernel |
HttpKernel |
request_stack |
RequestStack – current request: $app['request_stack']->getCurrentRequest() |
resolver, argument_resolver |
controller and argument resolvers |
routes |
RouteCollection |
url_generator, request_matcher, request_context |
routing |
controllers, controllers_factory |
main route collection and factory of new collections |
Built-in providers
RoutingServiceProvider
Registered automatically in the constructor. Calling register() again changes nothing – it can be left in place for
Silex compatibility.
As in Silex, a GET/HEAD request for a path that differs from the defined one only by a trailing / (e.g. /blog
with a /blog/ route) is redirected (301) to the route's path. The same applies to routes with requireHttps().
TwigServiceProvider
$app->register(new Silex\Provider\TwigServiceProvider(), [ 'twig.path' => __DIR__ . '/views', // string or array of paths; [path => 'name'] = namespace @name 'twig.templates' => ['hello' => 'Hello {{ name }}'], // in-memory templates 'twig.options' => ['cache' => __DIR__ . '/tmp/twig'], ]);
By default debug and strict_variables follow $app['debug'], and charset follows $app['charset'].
Template functions:
path(),url()asset(),asset_version()– requireAssetServiceProviderabsolute_url(),relative_path()dump()– only in debug mode
Global variables:
app– the application, e.g.app.debug,app['my.option']global– Symfony'sAppVariableobject:global.request,global.session,global.debug,global.environment
<a href="{{ path('agreement', {id: 5}) }}">Agreement</a> <link rel="stylesheet" href="{{ asset('css/style.css') }}"> {% if global.request.pathInfo == '/' %}Home page{% endif %}
AssetServiceProvider
$app->register(new Silex\Provider\AssetServiceProvider(), [ 'assets.base_path' => '/assets', // or 'assets.base_urls' => ['https://cdn.example.com'] 'assets.version' => 'v42', 'assets.version_format' => '%s?version=%s', // result: /assets/css/style.css?version=v42 // 'assets.json_manifest_path' => __DIR__ . '/public/manifest.json', // instead of assets.version 'assets.named_packages' => [ 'images' => ['base_urls' => ['https://img.example.com']], // {{ asset('logo.png', 'images') }} ], ]);
SessionServiceProvider
$app->register(new Silex\Provider\SessionServiceProvider(), [ 'session.storage.options' => ['name' => 'SID', 'cookie_lifetime' => 0, 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => 'lax'], 'session.storage.save_path' => '/tmp/sessions', ]); $app->post('/login', function (Request $request, Silex\Application $app) { $request->getSession()->set('user', 'john'); // or $app['session']->set(...) return $app->redirect('/'); }); $app->get('/', function (SessionInterface $session) { return 'Logged in as: ' . $session->get('user'); });
- The session starts only on first use and is saved before the response is sent.
- A custom handler (e.g. Redis, PDO) can be set by overriding the
$app['session.storage.handler']service. - In tests set
'session.test' => true, then the session is kept in memory. - The default
session.storage.optionsare['cookie_httponly' => true, 'cookie_samesite' => 'lax']; passing your own options replaces them entirely, so it is worth repeating them. Setcookie_secureyourself (trueor'auto') behind HTTPS.
CorsServiceProvider
CORS headers, parameters compatible with devimteam/silex-cors-provider:
$app->register(new Silex\Provider\CorsServiceProvider(), [ 'cors.allowOrigin' => 'https://a.example.com https://b.example.com', // default '*' – any origin 'cors.allowMethods' => ['GET', 'POST'], // default: methods of the routes matching the path 'cors.maxAge' => 15, 'cors.allowCredentials' => false, 'cors.exposeHeaders' => '', ]);
- Parameters are read on first use of the
corsservice, so they can still be set inbefore(). - For a disallowed origin no CORS headers are sent at all (previously
Access-Control-Allow-Origin: null, which allowed pages with anullorigin, e.g. a sandboxed iframe). Responses to requests with anOriginheader getVary: Origin. cors.allowOrigin='*'echoes the request origin; ifcors.allowCredentials=trueat the same time, a literal*is sent, which browsers do not accept for credentialed requests – this is intentional, so that a misconfiguration does not expose authenticated responses to any site.- An
OPTIONSrequest for a path that has routes gets a 204 response with anAllowheader (methods of those routes). For paths without routes – 404 as usual.
ServiceControllerServiceProvider
Allows defining controllers as methods of services from the container, which makes dependency injection easier:
$app->register(new Silex\Provider\ServiceControllerServiceProvider()); $app['posts.controller'] = fn($app) => new PostsController($app['db']); $app->get('/posts', 'posts.controller:indexAction');
Custom providers
A provider implements Pimple\ServiceProviderInterface. Optionally it may also implement:
Serwisant\Silex\Api\EventListenerProviderInterface–subscribe()for registering listeners,Serwisant\Silex\Api\BootableProviderInterface–boot()called after all providers have been registered.
Both methods are called once, when handling the first request: first subscribe() of all providers, then boot().
use Pimple\Container; use Pimple\ServiceProviderInterface; use Serwisant\Silex\Api\BootableProviderInterface; use Serwisant\Silex\Api\EventListenerProviderInterface; use Serwisant\Silex\Application; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpKernel\KernelEvents; class SecurityHeadersProvider implements ServiceProviderInterface, EventListenerProviderInterface, BootableProviderInterface { public function register(Container $app): void { $app['security_headers'] = ['X-Frame-Options' => 'DENY']; } public function subscribe(Container $app, EventDispatcherInterface $dispatcher): void { $dispatcher->addListener(KernelEvents::RESPONSE, function ($event) use ($app) { $event->getResponse()->headers->add($app['security_headers']); }); } public function boot(Application $app): void { // e.g. additional routes: $app->get('/health', fn() => 'OK'); } } $app->register(new SecurityHeadersProvider(), ['security_headers' => ['X-Frame-Options' => 'SAMEORIGIN']]);
Testing
Application implements HttpKernelInterface, so a request can be handled without an HTTP server:
use Symfony\Component\HttpFoundation\Request; $app = require __DIR__ . '/../app.php'; $response = $app->handle(Request::create('/agreements/5')); assert($response->getStatusCode() === 200); assert(str_contains($response->getContent(), 'Agreement'));
For POST routes: Request::create('/contact', 'POST', ['message' => ['body' => 'content']]).
Migrating from Silex 2
- In
composer.jsonreplacesilex/silexwithserwisant/serwisant-silex. Remove redundant packages:symfony/twig-bridge,symfony/assetetc. are dependencies of this package. - Replace
use Silex;/Silex\withuse Serwisant\Silex;/Serwisant\Silex\. - Custom providers: change the types in signatures to
Serwisant\Silex\Api\...and addvoidreturn types. Replacedevimteam/silex-cors-providerwithSerwisant\Silex\Provider\CorsServiceProvider. - Check the changes in Symfony itself between 3.x/4.x and 6.4+ that are not caused by this package. E.g.
$request->get()is deprecated since Symfony 7.4 – use$request->query->get(),$request->request->get()instead, and for arrays$request->request->all('key').
Differences from Silex
- PHP errors (
TypeError,Error) also reacherror()handlers – in Silex they ended with a fatal error. - When
bind()gives a route a name that was previously auto-generated for another route, that other route gets a different name instead of disappearing. - Route middleware and converters must be callables – there is no
'service:method'notation for converters and middleware. - The default error page comes from Symfony ErrorHandler (Silex had its own).
Not supported
Controller::when()(ExpressionLanguage conditions).- Silex providers other than those listed above: Security, Form, Validator, Translation, Monolog, Doctrine, Swiftmailer, HttpCache, HttpFragment, Csrf, Locale, VarDumper. If needed, they can be written as custom providers using the corresponding Symfony components.
- The
Application\TwigTrait,UrlGeneratorTraitetc. traits – their methods (render(),renderView(),path(),url()) are available directly inApplication.