Skip to content
  • About
    • What is Symfony?
    • Community
    • News
    • Contributing
    • Support
  • Documentation
    • Symfony Docs
    • Symfony Book
    • Screencasts
    • Symfony Bundles
    • Symfony Cloud
    • Training
  • Services
    • SensioLabs Professional services to help you with Symfony
    • Platform.sh for Symfony Best platform to deploy Symfony apps
    • SymfonyInsight Automatic quality checks for your apps
    • Symfony Certification Prove your knowledge and boost your career
    • Blackfire Profile and monitor performance of your apps
  • Other
  • Blog
  • Download
sponsored by SensioLabs
  1. Home
  2. Documentation
  3. Security
  4. How to Authenticate Users with API Keys
  • Documentation
  • Book
  • Reference
  • Bundles
  • Cloud

Table of Contents

  • The API Key Authenticator
    • 1. createToken
    • 2. supportsToken
    • 3. authenticateToken
    • The User Provider
  • Handling Authentication Failure
  • Configuration
  • Storing Authentication in the Session
  • Only Authenticating for Certain URLs

How to Authenticate Users with API Keys

Edit this page

Warning: You are browsing the documentation for Symfony 2.7, which is no longer maintained.

Read the updated version of this page for Symfony 6.2 (the current stable version).

How to Authenticate Users with API Keys

Nowadays, it's quite usual to authenticate the user via an API key (when developing a web service for instance). The API key is provided for every request and is passed as a query string parameter or via an HTTP header.

The API Key Authenticator

Authenticating a user based on the Request information should be done via a pre-authentication mechanism. The SimplePreAuthenticatorInterface allows you to implement such a scheme really easily.

Your exact situation may differ, but in this example, a token is read from an apikey query parameter, the proper username is loaded from that value and then a User object is created:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// src/AppBundle/Security/ApiKeyAuthenticator.php
namespace AppBundle\Security;

use AppBundle\Security\ApiKeyUserProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\SimplePreAuthenticatorInterface;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;

class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface
{
    public function createToken(Request $request, $providerKey)
    {
        // look for an apikey query parameter
        $apiKey = $request->query->get('apikey');

        // or if you want to use an "apikey" header, then do something like this:
        // $apiKey = $request->headers->get('apikey');

        if (!$apiKey) {
            throw new BadCredentialsException();

            // or to just skip api key authentication
            // return null;
        }

        return new PreAuthenticatedToken(
            'anon.',
            $apiKey,
            $providerKey
        );
    }

    public function supportsToken(TokenInterface $token, $providerKey)
    {
        return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
    }

    public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        if (!$userProvider instanceof ApiKeyUserProvider) {
            throw new \InvalidArgumentException(
                sprintf(
                    'The user provider must be an instance of ApiKeyUserProvider (%s was given).',
                    get_class($userProvider)
                )
            );
        }

        $apiKey = $token->getCredentials();
        $username = $userProvider->getUsernameForApiKey($apiKey);

        if (!$username) {
            throw new AuthenticationException(
                sprintf('API Key "%s" does not exist.', $apiKey)
            );
        }

        $user = $userProvider->loadUserByUsername($username);

        return new PreAuthenticatedToken(
            $user,
            $apiKey,
            $providerKey,
            $user->getRoles()
        );
    }
}

Once you've configured everything, you'll be able to authenticate by adding an apikey parameter to the query string, like http://example.com/api/foo?apikey=37b51d194a7513e45b56f6524f2d51f2.

The authentication process has several steps, and your implementation will probably differ:

1. createToken

Early in the request cycle, Symfony calls createToken(). Your job here is to create a token object that contains all of the information from the request that you need to authenticate the user (e.g. the apikey query parameter). If that information is missing, throwing a BadCredentialsException will cause authentication to fail. You might want to return null instead to just skip the authentication, so Symfony can fallback to another authentication method, if any.

Caution

In case you return null from your createToken() method, Symfony passes this request to the next authentication provider. If you haven't configured any other provider, enable the anonymous option in your firewall. This way Symfony executes the anonymous authentication provider and you'll get an AnonymousToken.

2. supportsToken

After Symfony calls createToken(), it will then call supportsToken() on your class (and any other authentication listeners) to figure out who should handle the token. This is just a way to allow several authentication mechanisms to be used for the same firewall (that way, you can for instance first try to authenticate the user via a certificate or an API key and fall back to a form login).

Mostly, you just need to make sure that this method returns true for a token that has been created by createToken(). Your logic should probably look exactly like this example.

3. authenticateToken

If supportsToken() returns true, Symfony will now call authenticateToken(). One key part is the $userProvider, which is an external class that helps you load information about the user. You'll learn more about this next.

In this specific example, the following things happen in authenticateToken():

  1. First, you use the $userProvider to somehow look up the $username that corresponds to the $apiKey;
  2. Second, you use the $userProvider again to load or create a User object for the $username;
  3. Finally, you create an authenticated token (i.e. a token with at least one role) that has the proper roles and the User object attached to it.

The goal is ultimately to use the $apiKey to find or create a User object. How you do this (e.g. query a database) and the exact class for your User object may vary. Those differences will be most obvious in your user provider.

The User Provider

The $userProvider can be any user provider (see How to Create a custom User Provider). In this example, the $apiKey is used to somehow find the username for the user. This work is done in a getUsernameForApiKey() method, which is created entirely custom for this use-case (i.e. this isn't a method that's used by Symfony's core user provider system).

The $userProvider might look something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// src/AppBundle/Security/ApiKeyUserProvider.php
namespace AppBundle\Security;

use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\User\User;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;

class ApiKeyUserProvider implements UserProviderInterface
{
    public function getUsernameForApiKey($apiKey)
    {
        // Look up the username based on the token in the database, via
        // an API call, or do something entirely different
        $username = ...;

        return $username;
    }

    public function loadUserByUsername($username)
    {
        return new User(
            $username,
            null,
            // the roles for the user - you may choose to determine
            // these dynamically somehow based on the user
            array('ROLE_API')
        );
    }

    public function refreshUser(UserInterface $user)
    {
        // this is used for storing authentication in the session
        // but in this example, the token is sent in each request,
        // so authentication can be stateless. Throwing this exception
        // is proper to make things stateless
        throw new UnsupportedUserException();
    }

    public function supportsClass($class)
    {
        return User::class === $class;
    }
}

Now register your user provider as a service:

  • YAML
  • XML
  • PHP
1
2
3
4
# app/config/services.yml
services:
    api_key_user_provider:
        class: AppBundle\Security\ApiKeyUserProvider
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- app/config/services.xml -->
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
    <services>
        <!-- ... -->

        <service id="api_key_user_provider"
            class="AppBundle\Security\ApiKeyUserProvider" />
    </services>
</container>
1
2
3
4
5
6
// app/config/services.php
use AppBundle\Security\ApiKeyUserProvider;

// ...
$container
    ->register('api_key_user_provider', ApiKeyUserProvider::class);

Note

Read the dedicated article to learn how to create a custom user provider.

The logic inside getUsernameForApiKey() is up to you. You may somehow transform the API key (e.g. 37b51d) into a username (e.g. jondoe) by looking up some information in a "token" database table.

The same is true for loadUserByUsername(). In this example, Symfony's core User class is simply created. This makes sense if you don't need to store any extra information on your User object (e.g. firstName). But if you do, you may instead have your own user class which you create and populate here by querying a database. This would allow you to have custom data on the User object.

Finally, just make sure that supportsClass() returns true for User objects with the same class as whatever user you return in loadUserByUsername().

If your authentication is stateless like in this example (i.e. you expect the user to send the API key with every request and so you don't save the login to the session), then you can simply throw the UnsupportedUserException exception in refreshUser().

Note

If you do want to store authentication data in the session so that the key doesn't need to be sent on every request, see How to Authenticate Users with API Keys.

Handling Authentication Failure

In order for your ApiKeyAuthenticator to correctly display a 401 http status when either bad credentials or authentication fails you will need to implement the AuthenticationFailureHandlerInterface on your Authenticator. This will provide a method onAuthenticationFailure() which you can use to create an error Response:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/AppBundle/Security/ApiKeyAuthenticator.php
namespace AppBundle\Security;

use Symfony\Component\Security\Core\Authentication\SimplePreAuthenticatorInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;

class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface, AuthenticationFailureHandlerInterface
{
    // ...

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        return new Response("Authentication Failed.", 401);
    }
}

Configuration

Once you have your ApiKeyAuthenticator all setup, you need to register it as a service and use it in your security configuration (e.g. security.yml). First, register it as a service.

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
# app/config/config.yml
services:
    # ...

    apikey_authenticator:
        class:  AppBundle\Security\ApiKeyAuthenticator
        public: false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- app/config/config.xml -->
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
    <services>
        <!-- ... -->

        <service id="apikey_authenticator"
            class="AppBundle\Security\ApiKeyAuthenticator"
            public="false" />
    </services>
</container>
1
2
3
4
5
6
7
8
// app/config/config.php
use AppBundle\Security\ApiKeyAuthenticator;
use Symfony\Component\DependencyInjection\Reference;

// ...

$container->register('apikey_authenticator', ApiKeyAuthenticator::class)
    ->setPublic(false);

Now, activate it and your custom user provider (see How to Create a custom User Provider) in the firewalls section of your security configuration using the simple_preauth and provider keys respectively:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# app/config/security.yml
security:
    # ...

    firewalls:
        secured_area:
            pattern: ^/api
            stateless: true
            simple_preauth:
                authenticator: apikey_authenticator
            provider: api_key_user_provider

    providers:
        api_key_user_provider:
            id: api_key_user_provider
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- app/config/security.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<srv:container xmlns="http://symfony.com/schema/dic/security"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:srv="http://symfony.com/schema/dic/services"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
    <config>
        <!-- ... -->

        <firewall name="secured_area"
            pattern="^/api"
            stateless="true"
            provider="api_key_user_provider"
        >
            <simple-preauth authenticator="apikey_authenticator" />
        </firewall>

        <provider name="api_key_user_provider" id="api_key_user_provider" />
    </config>
</srv:container>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// app/config/security.php

// ..

$container->loadFromExtension('security', array(
    'firewalls' => array(
        'secured_area'       => array(
            'pattern'        => '^/api',
            'stateless'      => true,
            'simple_preauth' => array(
                'authenticator'  => 'apikey_authenticator',
            ),
            'provider' => 'api_key_user_provider',
        ),
    ),
    'providers' => array(
        'api_key_user_provider'  => array(
            'id' => 'api_key_user_provider',
        ),
    ),
));

If you have defined access_control, make sure to add a new entry:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
# app/config/security.yml
security:
    # ...

    access_control:
        - { path: ^/api, roles: ROLE_API }
1
2
3
4
5
6
7
8
9
10
11
<!-- app/config/security.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<srv:container xmlns="http://symfony.com/schema/dic/security"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:srv="http://symfony.com/schema/dic/services"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
    <config>
        <rule path="^/api" role="ROLE_API" />
    </config>
</srv:container>
1
2
3
4
5
6
7
8
9
// app/config/security.php
$container->loadFromExtension('security', array(
    'access_control' => array(
        array(
            'path' => '^/api',
            'role' => 'ROLE_API',
        ),
    ),
));

That's it! Now, your ApiKeyAuthenticator should be called at the beginning of each request and your authentication process will take place.

The stateless configuration parameter prevents Symfony from trying to store the authentication information in the session, which isn't necessary since the client will send the apikey on each request. If you do need to store authentication in the session, keep reading!

Storing Authentication in the Session

So far, this entry has described a situation where some sort of authentication token is sent on every request. But in some situations (like an OAuth flow), the token may be sent on only one request. In this case, you will want to authenticate the user and store that authentication in the session so that the user is automatically logged in for every subsequent request.

To make this work, first remove the stateless key from your firewall configuration or set it to false:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# app/config/security.yml
security:
    # ...

    firewalls:
        secured_area:
            pattern: ^/api
            stateless: false
            simple_preauth:
                authenticator: apikey_authenticator
            provider: api_key_user_provider

    providers:
        api_key_user_provider:
            id: api_key_user_provider
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- app/config/security.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<srv:container xmlns="http://symfony.com/schema/dic/security"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:srv="http://symfony.com/schema/dic/services"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
    <config>
        <!-- ... -->

        <firewall name="secured_area"
            pattern="^/api"
            stateless="false"
            provider="api_key_user_provider"
        >
            <simple-preauth authenticator="apikey_authenticator" />
        </firewall>

        <provider name="api_key_user_provider" id="api_key_user_provider" />
    </config>
</srv:container>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// app/config/security.php

// ..
$container->loadFromExtension('security', array(
    'firewalls' => array(
        'secured_area'       => array(
            'pattern'        => '^/api',
            'stateless'      => false,
            'simple_preauth' => array(
                'authenticator'  => 'apikey_authenticator',
            ),
            'provider' => 'api_key_user_provider',
        ),
    ),
    'providers' => array(
        'api_key_user_provider' => array(
            'id' => 'api_key_user_provider',
        ),
    ),
));

Even though the token is being stored in the session, the credentials - in this case the API key (i.e. $token->getCredentials()) - are not stored in the session for security reasons. To take advantage of the session, update ApiKeyAuthenticator to see if the stored token has a valid User object that can be used:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// src/AppBundle/Security/ApiKeyAuthenticator.php
// ...

class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface
{
    // ...
    public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        if (!$userProvider instanceof ApiKeyUserProvider) {
            throw new \InvalidArgumentException(
                sprintf(
                    'The user provider must be an instance of ApiKeyUserProvider (%s was given).',
                    get_class($userProvider)
                )
            );
        }

        $apiKey = $token->getCredentials();
        $username = $userProvider->getUsernameForApiKey($apiKey);

        // User is the Entity which represents your user
        $user = $token->getUser();
        if ($user instanceof User) {
            return new PreAuthenticatedToken(
                $user,
                $apiKey,
                $providerKey,
                $user->getRoles()
            );
        }

        if (!$username) {
            throw new AuthenticationException(
                sprintf('API Key "%s" does not exist.', $apiKey)
            );
        }

        $user = $userProvider->loadUserByUsername($username);

        return new PreAuthenticatedToken(
            $user,
            $apiKey,
            $providerKey,
            $user->getRoles()
        );
    }
    // ...
}

Storing authentication information in the session works like this:

  1. At the end of each request, Symfony serializes the token object (returned from authenticateToken()), which also serializes the User object (since it's set on a property on the token);
  2. On the next request the token is deserialized and the deserialized User object is passed to the refreshUser() function of the user provider.

The second step is the important one: Symfony calls refreshUser() and passes you the user object that was serialized in the session. If your users are stored in the database, then you may want to re-query for a fresh version of the user to make sure it's not out-of-date. But regardless of your requirements, refreshUser() should now return the User object:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/AppBundle/Security/ApiKeyUserProvider.php

// ...
class ApiKeyUserProvider implements UserProviderInterface
{
    // ...

    public function refreshUser(UserInterface $user)
    {
        // $user is the User that you set in the token inside authenticateToken()
        // after it has been deserialized from the session

        // you might use $user to query the database for a fresh user
        // $id = $user->getId();
        // use $id to make a query

        // if you are *not* reading from a database and are just creating
        // a User object (like in this example), you can just return it
        return $user;
    }
}

Note

You'll also want to make sure that your User object is being serialized correctly. If your User object has private properties, PHP can't serialize those. In this case, you may get back a User object that has a null value for each property. For an example, see How to Load Security Users from the Database (the Entity Provider).

Only Authenticating for Certain URLs

This entry has assumed that you want to look for the apikey authentication on every request. But in some situations (like an OAuth flow), you only really need to look for authentication information once the user has reached a certain URL (e.g. the redirect URL in OAuth).

Fortunately, handling this situation is easy: just check to see what the current URL is before creating the token in createToken():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/AppBundle/Security/ApiKeyAuthenticator.php

// ...
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\HttpFoundation\Request;

class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface
{
    protected $httpUtils;

    public function __construct(HttpUtils $httpUtils)
    {
        $this->httpUtils = $httpUtils;
    }

    public function createToken(Request $request, $providerKey)
    {
        // set the only URL where we should look for auth information
        // and only return the token if we're at that URL
        $targetUrl = '/login/check';
        if (!$this->httpUtils->checkRequestPath($request, $targetUrl)) {
            return;
        }

        // ...
    }
}

This uses the handy HttpUtils class to check if the current URL matches the URL you're looking for. In this case, the URL (/login/check) has been hardcoded in the class, but you could also inject it as the second constructor argument.

Next, just update your service configuration to inject the security.http_utils service:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
# app/config/config.yml
services:
    # ...

    apikey_authenticator:
        class:     AppBundle\Security\ApiKeyAuthenticator
        arguments: ["@security.http_utils"]
        public:    false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- app/config/config.xml -->
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
    <services>
        <!-- ... -->

        <service id="apikey_authenticator"
            class="AppBundle\Security\ApiKeyAuthenticator"
            public="false"
        >
            <argument type="service" id="security.http_utils" />
        </service>
    </services>
</container>
1
2
3
4
5
6
7
8
9
// app/config/config.php
use AppBundle\Security\ApiKeyAuthenticator;
use Symfony\Component\DependencyInjection\Reference;

// ...

$container->register('apikey_authenticator', ApiKeyAuthenticator::class)
    ->addArgument(new Reference('security.http_utils'))
    ->setPublic(false);

That's it! Have fun!

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version
    We stand with Ukraine.
    Version:
    Code consumes server resources. Blackfire tells you how

    Code consumes server resources. Blackfire tells you how

    Be trained by SensioLabs experts (2 to 6 day sessions -- French or English).

    Be trained by SensioLabs experts (2 to 6 day sessions -- French or English).

    Symfony footer

    ↓ Our footer now uses the colors of the Ukrainian flag because Symfony stands with the people of Ukraine.

    Avatar of natechicago, a Symfony contributor

    Thanks natechicago for being a Symfony contributor

    1 commit • 14 lines changed

    View all contributors that help us make Symfony

    Become a Symfony contributor

    Be an active part of the community and contribute ideas, code and bug fixes. Both experts and newcomers are welcome.

    Learn how to contribute

    Symfony™ is a trademark of Symfony SAS. All rights reserved.

    • What is Symfony?

      • Symfony at a Glance
      • Symfony Components
      • Case Studies
      • Symfony Releases
      • Security Policy
      • Logo & Screenshots
      • Trademark & Licenses
      • symfony1 Legacy
    • Learn Symfony

      • Symfony Docs
      • Symfony Book
      • Reference
      • Bundles
      • Best Practices
      • Training
      • eLearning Platform
      • Certification
    • Screencasts

      • Learn Symfony
      • Learn PHP
      • Learn JavaScript
      • Learn Drupal
      • Learn RESTful APIs
    • Community

      • SymfonyConnect
      • Support
      • How to be Involved
      • Code of Conduct
      • Events & Meetups
      • Projects using Symfony
      • Downloads Stats
      • Contributors
      • Backers
    • Blog

      • Events & Meetups
      • A week of symfony
      • Case studies
      • Cloud
      • Community
      • Conferences
      • Diversity
      • Documentation
      • Living on the edge
      • Releases
      • Security Advisories
      • SymfonyInsight
      • Twig
      • SensioLabs
    • Services

      • SensioLabs services
      • Train developers
      • Manage your project quality
      • Improve your project performance
      • Host Symfony projects

      Deployed on

    Follow Symfony

    Search by Algolia