Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,14 @@ The extension auto-registers via `composer.json` `extra.phpstan.includes` when u

### Service Map

The extension reads Symfony's compiled container XML dump to build a map of services. This enables detection of unknown/private services and correct return types for `get()` calls. The `ServiceMap` interface has two implementations:
- `DefaultServiceMap` - populated from XML parsing
The extension reads Symfony's compiled container XML dump to build a map of services. This enables detection of unknown/private services and correct return types for `get()` calls. The `ServiceMap` interface has three implementations:
- `DefaultServiceMap` - populated from XML parsing via `XmlServiceMapFactory`
- `FakeServiceMap` - no-op fallback when no container XML is configured
- `LazyServiceMap` - lazy wrapper injected by the DI container; defers XML parsing until first access

### Parameter Map

Similar to ServiceMap, reads container parameters from the XML dump for type-aware `getParameter()` return types.
Similar to ServiceMap, reads container parameters from the XML dump for type-aware `getParameter()` return types. Has three implementations: `DefaultParameterMap` (from XML), `FakeParameterMap` (no-op fallback), and `LazyParameterMap` (lazy wrapper, same pattern as `LazyServiceMap`).

### Console Application Resolver

Expand Down
6 changes: 4 additions & 2 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ services:
arguments:
containerXmlPath: %symfony.containerXmlPath%
-
factory: @symfony.serviceMapFactory::create()
class: PHPStan\Symfony\ServiceMap
factory: PHPStan\Symfony\LazyServiceMap::create

# parameter map
symfony.parameterMapFactory:
Expand All @@ -57,7 +58,8 @@ services:
arguments:
containerXmlPath: %symfony.containerXmlPath%
-
factory: @symfony.parameterMapFactory::create()
class: PHPStan\Symfony\ParameterMap
factory: PHPStan\Symfony\LazyParameterMap::create

# message map
symfony.messageMapFactory:
Expand Down
58 changes: 58 additions & 0 deletions src/Symfony/LazyParameterMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php declare(strict_types = 1);

namespace PHPStan\Symfony;

use PhpParser\Node\Expr;
use PHPStan\Analyser\Scope;

abstract class LazyParameterMap implements ParameterMap
{

private function __construct()
{
}

final public static function create(ParameterMapFactory $parameterMapFactory): self
{
// Workaround to make the static method getParameterKeysFromNode() work without sharing state across all LazyParameterMap instances
$lazyParameterMap = new class () extends LazyParameterMap
{

protected static ParameterMapFactory $parameterMapFactory;

protected static ?ParameterMap $parameterMap;

protected static function getParameterMap(): ParameterMap
{
self::$parameterMap ??= self::$parameterMapFactory->create();
return self::$parameterMap;
}

};
$lazyParameterMap::$parameterMapFactory = $parameterMapFactory;
$lazyParameterMap::$parameterMap = null;
Comment on lines +32 to +33
Copy link
Copy Markdown
Contributor

@staabm staabm May 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we move this into a __construct of the above inline class?

same in LazyServiceMap

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not as long as LazyParameterMap::__construct remains private. The private constructor is intentional - it prevents any 3rd party inheritance.


return $lazyParameterMap;
}

abstract protected static function getParameterMap(): ParameterMap;

/**
* @return ParameterDefinition[]
*/
public function getParameters(): array
{
return static::getParameterMap()->getParameters();
}

public function getParameter(string $key): ?ParameterDefinition
{
return static::getParameterMap()->getParameter($key);
}

public static function getParameterKeysFromNode(Expr $node, Scope $scope): array
{
return static::getParameterMap()::getParameterKeysFromNode($node, $scope);
}

}
58 changes: 58 additions & 0 deletions src/Symfony/LazyServiceMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php declare(strict_types = 1);

namespace PHPStan\Symfony;

use PhpParser\Node\Expr;
use PHPStan\Analyser\Scope;

abstract class LazyServiceMap implements ServiceMap
{

private function __construct()
{
}

final public static function create(ServiceMapFactory $serviceMapFactory): self
{
// Workaround to make the static method getServiceIdFromNode() work without sharing state across all LazyServiceMap instances
$lazyServiceMap = new class () extends LazyServiceMap
{

public static ServiceMapFactory $serviceMapFactory;

public static ?ServiceMap $serviceMap;

protected static function getServiceMap(): ServiceMap
{
self::$serviceMap ??= self::$serviceMapFactory->create();
return self::$serviceMap;
}

};
$lazyServiceMap::$serviceMapFactory = $serviceMapFactory;
$lazyServiceMap::$serviceMap = null;

return $lazyServiceMap;
}

abstract protected static function getServiceMap(): ServiceMap;

/**
* @return ServiceDefinition[]
*/
public function getServices(): array
{
return static::getServiceMap()->getServices();
}

public function getService(string $id): ?ServiceDefinition
{
return static::getServiceMap()->getService($id);
}

public static function getServiceIdFromNode(Expr $node, Scope $scope): ?string
{
return static::getServiceMap()::getServiceIdFromNode($node, $scope);
}

}
42 changes: 42 additions & 0 deletions tests/Symfony/LazyParameterMapTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php declare(strict_types = 1);

namespace PHPStan\Symfony;

use PhpParser\Node\Expr\Variable;
use PHPStan\Analyser\Scope;
use PHPStan\Type\Constant\ConstantStringType;
use PHPUnit\Framework\TestCase;

final class LazyParameterMapTest extends TestCase
{

public function testFactoryIsNotCalledOnConstruction(): void
{
$factory = $this->createMock(ParameterMapFactory::class);
$factory->expects(self::never())->method('create');

LazyParameterMap::create($factory);
}

public function testDelegation(): void
{
$parameter = new Parameter('app.string', 'abcdef');
$innerMap = new DefaultParameterMap(['app.string' => $parameter]);

$factory = $this->createMock(ParameterMapFactory::class);
$factory->expects(self::once())->method('create')->willReturn($innerMap);

$lazyMap = LazyParameterMap::create($factory);

self::assertSame($innerMap->getParameters(), $lazyMap->getParameters());
self::assertSame($innerMap->getParameter('app.string'), $lazyMap->getParameter('app.string'));
self::assertNull($lazyMap->getParameter('unknown'));

$node = new Variable('x');
$scope = $this->createMock(Scope::class);
$scope->method('getType')->with($node)->willReturn(new ConstantStringType('app.string'));

self::assertSame($innerMap::getParameterKeysFromNode($node, $scope), $lazyMap::getParameterKeysFromNode($node, $scope));
}

}
42 changes: 42 additions & 0 deletions tests/Symfony/LazyServiceMapTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php declare(strict_types = 1);

namespace PHPStan\Symfony;

use PhpParser\Node\Expr\Variable;
use PHPStan\Analyser\Scope;
use PHPStan\Type\Constant\ConstantStringType;
use PHPUnit\Framework\TestCase;

final class LazyServiceMapTest extends TestCase
{

public function testFactoryIsNotCalledOnConstruction(): void
{
$factory = $this->createMock(ServiceMapFactory::class);
$factory->expects(self::never())->method('create');

LazyServiceMap::create($factory);
}

public function testDelegation(): void
{
$service = new Service('withClass', 'Foo', false, false, null);
$innerMap = new DefaultServiceMap(['withClass' => $service]);

$factory = $this->createMock(ServiceMapFactory::class);
$factory->expects(self::once())->method('create')->willReturn($innerMap);

$lazyMap = LazyServiceMap::create($factory);

self::assertSame($innerMap->getServices(), $lazyMap->getServices());
self::assertSame($innerMap->getService('withClass'), $lazyMap->getService('withClass'));
self::assertNull($lazyMap->getService('unknown'));

$node = new Variable('x');
$scope = $this->createMock(Scope::class);
$scope->method('getType')->with($node)->willReturn(new ConstantStringType('withClass'));

self::assertSame($innerMap::getServiceIdFromNode($node, $scope), $lazyMap::getServiceIdFromNode($node, $scope));
}

}
Loading