How ZF1 Users Log Into Symfony With Cross-Framework Authentication
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.

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:
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:
$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, Symphony 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, about two weeks before the rest of the bridge). Two reasons:
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.
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