From 602b7eba95281c11cdb9b9ae36b58a2157871e49 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 11 Jun 2026 17:14:00 +0200 Subject: [PATCH] feat(laravel): boot without a database via dumped metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Platform reads Eloquent model metadata from the live database schema while building resource metadata. That build runs at boot (route registration iterates every resource), so the app cannot boot when no migrated database is reachable — breaking Docker image builds, `composer install` (package:discover) and static analysis in CI. Add `api-platform:metadata:dump`, which computes every resource's ResourceMetadataCollection with the database up and serializes the map to a file. A new DumpedResourceCollectionMetadataFactory is wired as the outermost resource metadata factory: when a dump file is configured it serves metadata from the file and short-circuits the database-reading factories. The decorator is skipped when APP_DEBUG is true so local development always recomputes fresh metadata. The dump file can be committed to the repository or baked into a Docker image, letting the app boot with no database connection. Refs #8131 --- src/Laravel/ApiPlatformProvider.php | 16 +++ src/Laravel/Console/DumpMetadataCommand.php | 80 +++++++++++++ ...umpedResourceCollectionMetadataFactory.php | 75 ++++++++++++ .../Tests/Console/DumpMetadataCommandTest.php | 112 ++++++++++++++++++ .../Tests/Metadata/DumpedMetadataBootTest.php | 79 ++++++++++++ ...dResourceCollectionMetadataFactoryTest.php | 88 ++++++++++++++ src/Laravel/config/api-platform.php | 7 ++ 7 files changed, 457 insertions(+) create mode 100644 src/Laravel/Console/DumpMetadataCommand.php create mode 100644 src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php create mode 100644 src/Laravel/Tests/Console/DumpMetadataCommandTest.php create mode 100644 src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php create mode 100644 src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 889f7703b49..c5daa0d193b 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -98,6 +98,7 @@ use ApiPlatform\Laravel\JsonApi\State\JsonApiProvider; use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory; use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory; +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; use ApiPlatform\Laravel\Routing\IriConverter; use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter; use ApiPlatform\Laravel\Routing\SkolemIriConverter; @@ -402,6 +403,20 @@ public function register(): void return new Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory($inner, $app->make(ModelMetadata::class), new \Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter()); }); + // Outermost: serve the resource metadata from a dumped file so the app can boot without a + // live database. Skipped when APP_DEBUG is true so local development always recomputes fresh + // metadata (mirroring the 'array' cache choice). + $this->app->extend(ResourceMetadataCollectionFactoryInterface::class, static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) { + /** @var ConfigRepository $config */ + $config = $app['config']; + + if (true === $config->get('app.debug')) { + return $inner; + } + + return new DumpedResourceCollectionMetadataFactory($inner, $config->get('api-platform.metadata_dump')); + }); + $this->app->singleton(OperationMetadataFactory::class, static function (Application $app) { return new OperationMetadataFactory($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class)); }); @@ -1110,6 +1125,7 @@ public function register(): void if ($this->app->runningInConsole()) { $this->commands([ Console\InstallCommand::class, + Console\DumpMetadataCommand::class, Console\Maker\MakeStateProcessorCommand::class, Console\Maker\MakeStateProviderCommand::class, Console\Maker\MakeFilterCommand::class, diff --git a/src/Laravel/Console/DumpMetadataCommand.php b/src/Laravel/Console/DumpMetadataCommand.php new file mode 100644 index 00000000000..6b05aff25f0 --- /dev/null +++ b/src/Laravel/Console/DumpMetadataCommand.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Console; + +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Illuminate\Console\Command; +use Symfony\Component\Console\Attribute\AsCommand; + +#[AsCommand(name: 'api-platform:metadata:dump')] +final class DumpMetadataCommand extends Command +{ + /** + * @var string + */ + protected $signature = 'api-platform:metadata:dump {--path= : Where to write the dumped metadata file (defaults to the api-platform.metadata_dump config value)}'; + + /** + * @var string + */ + protected $description = 'Dump the resource metadata to a file so the app can boot without hitting the database'; + + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + ) { + parent::__construct(); + } + + public function handle(): int + { + $path = $this->option('path') ?: config('api-platform.metadata_dump'); + + if (!\is_string($path) || '' === $path) { + $this->error('No dump path configured. Pass --path or set the "api-platform.metadata_dump" config value.'); + + return self::FAILURE; + } + + // Always rebuild from the live source, never from a previously dumped (possibly stale) file. + $factory = $this->resourceMetadataCollectionFactory; + while ($factory instanceof DumpedResourceCollectionMetadataFactory) { + $factory = $factory->getDecorated(); + } + + $metadata = []; + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $metadata[$resourceClass] = $factory->create($resourceClass); + } + + $directory = \dirname($path); + if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) { + $this->error(\sprintf('Unable to create directory "%s".', $directory)); + + return self::FAILURE; + } + + if (false === file_put_contents($path, serialize($metadata))) { + $this->error(\sprintf('Unable to write the metadata dump to "%s".', $path)); + + return self::FAILURE; + } + + $this->info(\sprintf('Dumped metadata for %d resource(s) to "%s".', \count($metadata), $path)); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php b/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php new file mode 100644 index 00000000000..916c0e68f6c --- /dev/null +++ b/src/Laravel/Metadata/DumpedResourceCollectionMetadataFactory.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Metadata; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; + +/** + * Serves the resource metadata from a file dumped by api-platform:metadata:dump, bypassing the + * database introspection that happens while building the collection. Delegates to the decorated + * factory for any resource missing from the dump (or when no dump file exists). + */ +final class DumpedResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface +{ + /** + * @var array|null + */ + private ?array $dumped = null; + + public function __construct( + private readonly ResourceMetadataCollectionFactoryInterface $decorated, + private readonly ?string $dumpPath, + ) { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $dumped = $this->load(); + + return $dumped[$resourceClass] ?? $this->decorated->create($resourceClass); + } + + /** + * Exposes the decorated factory so the dump command can rebuild metadata from the live source + * instead of reading back a previously dumped (possibly stale) file. + */ + public function getDecorated(): ResourceMetadataCollectionFactoryInterface + { + return $this->decorated; + } + + /** + * @return array + */ + private function load(): array + { + if (null !== $this->dumped) { + return $this->dumped; + } + + if (null === $this->dumpPath || !is_file($this->dumpPath)) { + return $this->dumped = []; + } + + $contents = file_get_contents($this->dumpPath); + if (false === $contents) { + return $this->dumped = []; + } + + $data = unserialize($contents, ['allowed_classes' => true]); + + return $this->dumped = \is_array($data) ? $data : []; + } +} diff --git a/src/Laravel/Tests/Console/DumpMetadataCommandTest.php b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php new file mode 100644 index 00000000000..a034f1a17ea --- /dev/null +++ b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests\Console; + +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use Illuminate\Console\Command; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class DumpMetadataCommandTest extends TestCase +{ + use WithWorkbench; + + private string $dumpPath; + + protected function setUp(): void + { + parent::setUp(); + + $this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_dump_cmd_').'.meta'; + @unlink($this->dumpPath); + } + + protected function tearDown(): void + { + if (is_file($this->dumpPath)) { + unlink($this->dumpPath); + } + + parent::tearDown(); + } + + public function testItDumpsTheResourceMetadataCollectionMapToTheGivenFile(): void + { + $classOne = 'App\\Resource\\One'; + $classTwo = 'App\\Resource\\Two'; + + $collectionOne = new ResourceMetadataCollection($classOne, [new ApiResource(shortName: 'One')]); + $collectionTwo = new ResourceMetadataCollection($classTwo, [new ApiResource(shortName: 'Two')]); + + $nameFactory = $this->createStub(ResourceNameCollectionFactoryInterface::class); + $nameFactory->method('create')->willReturn(new ResourceNameCollection([$classOne, $classTwo])); + + $metadataFactory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $metadataFactory->method('create')->willReturnCallback(static fn (string $class): ResourceMetadataCollection => match ($class) { + $classOne => $collectionOne, + $classTwo => $collectionTwo, + }); + + $this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory); + $this->app->instance(ResourceMetadataCollectionFactoryInterface::class, $metadataFactory); + + $this->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath]) + ->assertExitCode(Command::SUCCESS); + + $this->assertFileExists($this->dumpPath); + + $dumped = unserialize(file_get_contents($this->dumpPath), ['allowed_classes' => true]); + + $this->assertIsArray($dumped); + $this->assertArrayHasKey($classOne, $dumped); + $this->assertArrayHasKey($classTwo, $dumped); + $this->assertEquals($collectionOne, $dumped[$classOne]); + $this->assertEquals($collectionTwo, $dumped[$classTwo]); + } + + public function testItRebuildsFromTheLiveSourceEvenWhenTheResolvedFactoryIsTheDumpedDecorator(): void + { + $class = 'App\\Resource\\Fresh'; + + $fresh = new ResourceMetadataCollection($class, [new ApiResource(shortName: 'Fresh')]); + $stale = new ResourceMetadataCollection($class, [new ApiResource(shortName: 'Stale')]); + + // Simulate an already-present (stale) dump on disk. + file_put_contents($this->dumpPath, serialize([$class => $stale])); + + $nameFactory = $this->createStub(ResourceNameCollectionFactoryInterface::class); + $nameFactory->method('create')->willReturn(new ResourceNameCollection([$class])); + + $live = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $live->method('create')->willReturn($fresh); + + // The resolved factory is a DumpedResourceCollectionMetadataFactory pointing at the stale file. + $dumpedFactory = new DumpedResourceCollectionMetadataFactory($live, $this->dumpPath); + + $this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory); + $this->app->instance(ResourceMetadataCollectionFactoryInterface::class, $dumpedFactory); + + $this->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath]) + ->assertExitCode(Command::SUCCESS); + + $dumped = unserialize(file_get_contents($this->dumpPath), ['allowed_classes' => true]); + + $this->assertEquals($fresh, $dumped[$class]); + } +} diff --git a/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php b/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php new file mode 100644 index 00000000000..0b1984fa126 --- /dev/null +++ b/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests\Metadata; + +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class DumpedMetadataBootTest extends TestCase +{ + use WithWorkbench; + + private const RESOURCE_CLASS = 'App\\NotAnEloquentModel'; + + private string $dumpPath; + + protected function setUp(): void + { + $this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_boot_dump_').'.meta'; + + $dumped = new ResourceMetadataCollection(self::RESOURCE_CLASS, [new ApiResource(shortName: 'FromDump')]); + file_put_contents($this->dumpPath, serialize([self::RESOURCE_CLASS => $dumped])); + + parent::setUp(); + } + + protected function tearDown(): void + { + if (is_file($this->dumpPath)) { + unlink($this->dumpPath); + } + + parent::tearDown(); + } + + protected function defineEnvironment($app): void + { + $app['config']->set('app.debug', false); + $app['config']->set('api-platform.metadata_dump', $this->dumpPath); + } + + public function testItServesMetadataFromTheDumpWithoutHittingTheDatabase(): void + { + $factory = $this->app->make(ResourceMetadataCollectionFactoryInterface::class); + + $this->assertInstanceOf(DumpedResourceCollectionMetadataFactory::class, $factory); + + // The class is not a real Eloquent model; if the dump were not consulted the inner + // factory chain would try to introspect a non-existent model/table. + $metadata = $factory->create(self::RESOURCE_CLASS); + + $this->assertCount(1, $metadata); + $this->assertSame('FromDump', $metadata[0]->getShortName()); + } + + public function testItIsNotWrappedWhenDebugIsEnabled(): void + { + $this->app['config']->set('app.debug', true); + $this->app->forgetInstance(ResourceMetadataCollectionFactoryInterface::class); + + $factory = $this->app->make(ResourceMetadataCollectionFactoryInterface::class); + + $this->assertNotInstanceOf(DumpedResourceCollectionMetadataFactory::class, $factory); + } +} diff --git a/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php b/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php new file mode 100644 index 00000000000..4ca95f5d293 --- /dev/null +++ b/src/Laravel/Tests/Unit/Metadata/DumpedResourceCollectionMetadataFactoryTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests\Unit\Metadata; + +use ApiPlatform\Laravel\Metadata\DumpedResourceCollectionMetadataFactory; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use PHPUnit\Framework\TestCase; +use Workbench\App\Models\Book; + +class DumpedResourceCollectionMetadataFactoryTest extends TestCase +{ + private string $dumpPath; + + protected function setUp(): void + { + $this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_dump_').'.meta'; + } + + protected function tearDown(): void + { + if (is_file($this->dumpPath)) { + unlink($this->dumpPath); + } + } + + public function testItReturnsTheDumpedCollectionWithoutCallingTheDecoratedFactory(): void + { + $dumped = new ResourceMetadataCollection(Book::class, [new ApiResource(shortName: 'Book')]); + file_put_contents($this->dumpPath, serialize([Book::class => $dumped])); + + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->never())->method('create'); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, $this->dumpPath); + + $result = $factory->create(Book::class); + + $this->assertEquals($dumped, $result); + } + + public function testItDelegatesForAClassMissingFromTheDump(): void + { + file_put_contents($this->dumpPath, serialize([Book::class => new ResourceMetadataCollection(Book::class, [])])); + + $expected = new ResourceMetadataCollection('Unknown', [new ApiResource(shortName: 'Unknown')]); + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with('Unknown')->willReturn($expected); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, $this->dumpPath); + + $this->assertSame($expected, $factory->create('Unknown')); + } + + public function testItDelegatesWhenNoDumpPathConfigured(): void + { + $expected = new ResourceMetadataCollection(Book::class, [new ApiResource(shortName: 'Book')]); + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(Book::class)->willReturn($expected); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, null); + + $this->assertSame($expected, $factory->create(Book::class)); + } + + public function testItDelegatesWhenDumpFileIsAbsent(): void + { + $expected = new ResourceMetadataCollection(Book::class, [new ApiResource(shortName: 'Book')]); + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(Book::class)->willReturn($expected); + + $factory = new DumpedResourceCollectionMetadataFactory($decorated, '/nonexistent/path/api_platform_metadata.meta'); + + $this->assertSame($expected, $factory->create(Book::class)); + } +} diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 38e0de76e9f..b9243f79594 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -178,6 +178,13 @@ // we recommend using "file" or "acpu" 'cache' => 'file', + // Path to a resource metadata file produced by `php artisan api-platform:metadata:dump`. + // When set (and APP_DEBUG is false), the metadata is read from this file at boot instead + // of being introspected from the database, allowing the app to boot without a live DB + // (e.g. during `docker build`, `composer install`, or static analysis in CI). Commit the + // file to VCS or bake it into your image. Leave null to disable. + 'metadata_dump' => null, + // MCP (Model Context Protocol) configuration 'mcp' => [ 'enabled' => true,