# How ZF1 Users Log Into Symfony With Cross-Framework Authentication

Published 2026-05-12. Updated 2026-09-17.
Canonical URL https://log.delaa.dev/blog/2026-05-12-zf1-users-log-into-symfony-cross-framework-auth

> During a Strangler Fig migration, both frameworks need to recognize the same user. Here is how a Symfony authenticator read Zend_Auth session data to create seamless single sign-on across ZF1 and Symfony, and why it was the first bridge component to be removed.

---

<Image
  src="/images/cross-framework-auth-zf1-symfony/hero.png"
  alt="Two open doors with different framework icons connected by a shared key in the middle"
  width={2000}
  height={1125}
  className="rounded-lg"
/>

Authentication during a Strangler Fig migration has a chicken-and-egg
problem. Which framework handles login? If a user logs in via ZF1, how
does Symfony know they are authenticated? If Symfony handles it, how
does ZF1 recognize the session?

Our solution was a **session-based authenticator** that read Zend_Auth
data from the shared PHP session and created a Symfony User from it.

## The ZendAuthAuthenticator

`ZendAuthAuthenticator` was a Symfony `AbstractAuthenticator` that
checked the Symfony session for Zend_Auth data:

```php
class ZendAuthAuthenticator extends AbstractAuthenticator
{
    public function supports(Request $request): ?bool
    {
        if (! $request->hasSession()) {
            return false;
        }
        $zendAuth = $request->getSession()->get('Zend_Auth');
        return isset($zendAuth['storage']);
    }

    public function authenticate(Request $request): Passport
    {
        $zendAuthSession = $request->getSession()->get('Zend_Auth');
        $zendAuth = $zendAuthSession['storage'] ?? null;

        $userId = null;
        $username = 'legacy_user';

        // Zend_Auth stores user data — could be stdClass or array
        if (is_object($zendAuth)) {
            $userId = $zendAuth->id ?? null;
            $username = $zendAuth->username ?? 'legacy_user';
        } elseif (is_array($zendAuth)) {
            $userId = $zendAuth['id'] ?? null;
            $username = $zendAuth['username'] ?? 'legacy_user';
        }

        if ($userId === null) {
            throw new AuthenticationException('No user ID found in Zend session.');
        }

        return new SelfValidatingPassport(
            new UserBadge((string) $userId, function () use ($userId, $username) {
                // Map legacy roles to Symfony ROLE_ format
                return new User((int) $userId, $username, $roles);
            })
        );
    }
}
```

The `supports()` method is the key. It silently skips itself for
anonymous users or users already authenticated through Symfony's native
mechanism. `authenticate()` runs only when Zend_Auth data is present.

## The serialization format problem

Zend Framework 1 stores authenticated user data in `Zend_Auth` session
namespace as a **stdClass** by default. PHP sessions serialize objects
using PHP's native serializer. If anything changes the session
serialization, like a PHP version upgrade, a different `session.serialize_handler`,
or a custom session handler, the stdClass becomes an array.

The authenticator had to handle both:

```php
$userId = is_object($zendAuth)
    ? ($zendAuth->id ?? null)
    : ($zendAuth['id'] ?? null);
```

This is fragile. A PHP upgrade during the migration could silently break
authentication for every user who had an active session from the ZF1 era.
We caught this during testing. The session format changed between PHP 8.2
and 8.4, and suddenly all legacy sessions appeared empty.

## The success and failure handling

`onAuthenticationSuccess()` returned null, letting the request continue
normally. The user was authenticated, their Symfony session was
populated, and subsequent requests would use Symfony's native
authentication.

`onAuthenticationFailure()` also returned null, deliberately. An
anonymous user (no Zend_Auth in session) should not be blocked. They
would proceed as anonymous in Symfony, and if the route was behind
a security firewall, Symfony would handle the redirect to login.

## Why it was the first component removed

Authentication was the first bridge component to be removed (commit
`1d36b2ad`, 2026-05-05). Two reasons:

1. **Auth was fully migrated early.** Once all login routes and user
   management were ported to Symfony, new sessions were created by
   Symfony's security system. No new Zend_Auth sessions were created.

2. **The session format risk was not worth carrying.** Every PHP
   version upgrade or session handler change could break legacy session
   reads. The ZendAuthAuthenticator was a liability, not an asset, once
   the migration was past the halfway point.

## Timing matters

The authenticator was introduced in commit `34a091d1` (2026-02-21),
the same day as the Strangler Fig Router. It was removed in commit
`1d36b2ad` (2026-05-05). It was active for about **two and a half
months**, the shortest lifespan of any bridge component.

This is a useful data point for anyone planning a Strangler Fig
migration. **Migrate authentication first.** It is the component with
the most cross-cutting dependencies, and removing it early reduces risk
for the rest of the migration.

---

— Delaa
