PHP Native Session with Laravel

October 18, 2020

Completely replacing legacy code is hard. Rebuilding years of work doesn't happen overnight. The strangler pattern offers a strategy to slowly and gradually replace a legacy codebase with a fresh one piece by piece. One of the key aspects of getting a successful project get started in replacing a legacy codebase was keeping the authentication system working. Let me set the scene.

Context

A 15-year old codebase written in PHP using $_SESSION across several thousands of files lives behind a web server running under api.mydomain.com. It acts as a backend api for a frontend application that establish these API calls using PHP browser's cookie (PHPSESSID). We can start a brand new project and call it api.v2.mydomain.com or we can use path-based routing and define an ALB routing on AWS where api.mydomain.com/v2 automatically routes to this second project. The new project can take advantage of modern php development (a public framework, composer, tdd, etc), but developing a brand new authentication system is a huge undertake at this point. It requires changes to the authentication process, the frontend and the backend project. A less disruptive process would be to simply focus on making the v2 project viable while keeping as much backward compatibility as possible.

Laravel Authentication with $_SESSION

Laravel PHP Session is a package that helps achieve exactly that. We install it via composer require customergauge/session and configure auth.php with a new authentication mechanism.

    'guards' => [
        'php' => [
            'driver' => \CustomerGauge\Session\NativeSessionGuard::class,
            'provider' => \CustomerGauge\Session\NativeSessionUserProvider::class,
            'domain' => '.mydomain.com',
            'storage' => 'tcp://my-redis-address:6379',
        ],
    ],

The domain attribute is directly mapped to PHP's COOKIE_DOMAIN ini setting and the storage is mapped to SESSION_PATH.

The next step is to implement the UserFactory interface and bind it into Laravel's Container.

final class NativeSessionUserFactory implements UserFactory
{
    public function make(array $session): ?Authenticatable
    {
        // $session here is the same as $_SESSION
        if (empty($session['id'])) {
            return null;
        }

        return new MyUser(
            (int) $session['id'],
            $session['my_session_attribute']
        );
    }
}

Laravel's auth middleware allows us to specify a guard when declaring a route as authenticatable. See more on https://laravel.com/docs/8.x/authentication#protecting-routes. As long as our route is protected by auth:php, the authentication process will be triggered.

Test Code

Laravel already provides the actingAs functionality in it's TestCase classes. That functionality will allow us to bind a specific user object into the container so that the application treat the execution test as authenticated. To avoid any problems with the PHP Native session, we can also bind a mock instance of SessionRetriever into the container

    protected function setUp(): void
    {
        parent::setUp();

        $fakeSession = ['id' => 'my-fake-$_SESSION-content'];

        $this->app->instance(SessionRetriever::class, SessionRetriever::fake($fakeSession));
    }

This bind will prevent any execution path that might lead to SessionRetriever calling sesion_start(), which would fail under phpunit as there's no headers to be sent.

Conclusion

I've been using this strategy for well over 3 years now and it empowered my team to strangle more and more our legacy codebase into a modern application in small, incremental steps. The more code we move to services designed with this mindset, the less $_SESSION spread across a codebase we have and the closer we get to having a consistent authentication mechanism that might easily be replaced by another Laravel Guard. As a matter of fact, we already have a Laravel Guard for AWS Cognito that I wrote about here.

As always, hit me up on Twitter with any questions.

Cheers.