Search by

serwisant / serwisant-silex

arekk

Minimal Silex-compatible micro-framework built on Symfony components and Pimple

Package info

github.com/SerwisantOnline/serwisant-silex

pkg:composer/serwisant/serwisant-silex

Statistics

Installs: 16

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-10-21 19:31 UTC

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

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 with bind('')) get a name generated from the method and path, e.g. POST_sg_contact.
  • A GET route also handles HEAD.
  • A request with a method the route does not support ends with 405 and an Allow header.
  • URLs are generated with $app->path('agreement', ['id' => 5]) and $app->url(...) (absolute URL), and in Twig with path() and url().

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 (from SessionServiceProvider),
  • 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 (including JsonResponse, RedirectResponse, StreamedResponse...),
  • a string or a Stringable object – it will be converted to a Response with 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

  1. $app->before() – application middleware
  2. ->before() – route middleware
  3. controller
  4. view handlers (if the controller did not return a Response)
  5. ->after() – route middleware
  6. $app->after() – application middleware
  7. sending the response
  8. $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');
  • $code is the HTTP status code: taken from an HttpException (e.g. 404, 405, 403) or 500 for other errors.
  • A handler returning a Response or a string ends error handling. A returned string goes into a response with status $code.
  • A handler returning null passes 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. In debug mode 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() – require AssetServiceProvider
  • absolute_url(), relative_path()
  • dump() – only in debug mode

Global variables:

  • app – the application, e.g. app.debug, app['my.option']
  • global – Symfony's AppVariable object: 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.options are ['cookie_httponly' => true, 'cookie_samesite' => 'lax']; passing your own options replaces them entirely, so it is worth repeating them. Set cookie_secure yourself (true or '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 cors service, so they can still be set in before().
  • For a disallowed origin no CORS headers are sent at all (previously Access-Control-Allow-Origin: null, which allowed pages with a null origin, e.g. a sandboxed iframe). Responses to requests with an Origin header get Vary: Origin.
  • cors.allowOrigin = '*' echoes the request origin; if cors.allowCredentials = true at 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 OPTIONS request for a path that has routes gets a 204 response with an Allow header (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

  1. In composer.json replace silex/silex with serwisant/serwisant-silex. Remove redundant packages: symfony/twig-bridge, symfony/asset etc. are dependencies of this package.
  2. Replace use Silex; / Silex\ with use Serwisant\Silex; / Serwisant\Silex\.
  3. Custom providers: change the types in signatures to Serwisant\Silex\Api\... and add void return types. Replace devimteam/silex-cors-provider with Serwisant\Silex\Provider\CorsServiceProvider.
  4. 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 reach error() 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, UrlGeneratorTrait etc. traits – their methods (render(), renderView(), path(), url()) are available directly in Application.