Skip to content

Commit 5604b09

Browse files
committed
feat(laravel): boot without a database via dumped metadata
Add `php artisan api-platform:metadata:dump`, which introspects the Eloquent schema once and writes the per-model attribute and relation metadata to a file. When `api-platform.metadata_dump` points at that file and APP_DEBUG is false, ModelMetadata is seeded from it at boot, so the resource metadata chain builds without a database — e.g. during `docker build`, `composer install` or static analysis in CI. ModelMetadata is the only boot-time component that touches the database, so caching it alone lets the whole chain boot offline while every factory stays live. The dump holds plain attribute and relation arrays (scalars and class-strings), read back with allowed_classes disabled, so it can never carry a Closure and adds no object-injection surface. It is written via a temporary file and renamed into place so a concurrent boot never reads a half-written file.
1 parent 134bb5c commit 5604b09

7 files changed

Lines changed: 429 additions & 3 deletions

File tree

src/Laravel/ApiPlatformProvider.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,33 @@ public function register(): void
241241
);
242242
});
243243

244-
$this->app->singleton(ModelMetadata::class, static function () {
244+
// Serve the Eloquent model metadata from a dumped file so the app can boot without a live
245+
// database. Skipped when APP_DEBUG is true so local development always introspects fresh.
246+
// The dump holds only attribute/relation arrays (plain scalars and class-strings), so it is
247+
// read back with allowed_classes disabled. The dump command gets a live, unseeded instance
248+
// (contextual binding below) so it never reads back its own dump.
249+
$this->app->singleton(ModelMetadata::class, static function (Application $app) {
250+
/** @var ConfigRepository $config */
251+
$config = $app['config'];
252+
$dumpPath = $config->get('api-platform.metadata_dump');
253+
254+
if (true !== $config->get('app.debug') && \is_string($dumpPath) && is_file($dumpPath)) {
255+
$contents = file_get_contents($dumpPath);
256+
if (false !== $contents) {
257+
$data = @unserialize($contents, ['allowed_classes' => false]);
258+
if (\is_array($data) && \is_array($data['attributes'] ?? null) && \is_array($data['relations'] ?? null)) {
259+
return new ModelMetadata(attributes: $data['attributes'], relations: $data['relations']);
260+
}
261+
}
262+
}
263+
245264
return new ModelMetadata();
246265
});
247266

267+
$this->app->when(Console\DumpMetadataCommand::class)
268+
->needs(ModelMetadata::class)
269+
->give(static fn () => new ModelMetadata());
270+
248271
$this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class);
249272
$this->app->singleton(ClassMetadataFactory::class, static function (Application $app) {
250273
/** @var ConfigRepository */
@@ -1110,6 +1133,7 @@ public function register(): void
11101133
if ($this->app->runningInConsole()) {
11111134
$this->commands([
11121135
Console\InstallCommand::class,
1136+
Console\DumpMetadataCommand::class,
11131137
Console\Maker\MakeStateProcessorCommand::class,
11141138
Console\Maker\MakeStateProviderCommand::class,
11151139
Console\Maker\MakeFilterCommand::class,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Console;
15+
16+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
17+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
18+
use Illuminate\Console\Command;
19+
use Illuminate\Database\Eloquent\Model;
20+
use Symfony\Component\Console\Attribute\AsCommand;
21+
22+
#[AsCommand(name: 'api-platform:metadata:dump')]
23+
final class DumpMetadataCommand extends Command
24+
{
25+
/**
26+
* @var string
27+
*/
28+
protected $signature = 'api-platform:metadata:dump {--path= : Where to write the dumped metadata file (defaults to the api-platform.metadata_dump config value)}';
29+
30+
/**
31+
* @var string
32+
*/
33+
protected $description = 'Dump the Eloquent model metadata to a file so the app can boot without hitting the database';
34+
35+
public function __construct(
36+
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
37+
private readonly ModelMetadata $modelMetadata,
38+
) {
39+
parent::__construct();
40+
}
41+
42+
public function handle(): int
43+
{
44+
$path = $this->option('path');
45+
if (null === $path || '' === $path) {
46+
$path = config('api-platform.metadata_dump');
47+
}
48+
49+
if (!\is_string($path) || '' === $path) {
50+
$this->error('No dump path configured. Pass --path or set the "api-platform.metadata_dump" config value.');
51+
52+
return self::FAILURE;
53+
}
54+
55+
// The container hands this command a live ModelMetadata (contextual binding), so introspection
56+
// always reads the database rather than a previously dumped (possibly stale) cache. Only the
57+
// resulting attribute/relation arrays are dumped — plain scalars and class-strings, never
58+
// objects — so the dump is safe to unserialize with allowed_classes disabled.
59+
$attributes = [];
60+
$relations = [];
61+
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
62+
try {
63+
$model = (new \ReflectionClass($resourceClass))->newInstanceWithoutConstructor();
64+
} catch (\ReflectionException) {
65+
continue;
66+
}
67+
68+
if (!$model instanceof Model) {
69+
continue;
70+
}
71+
72+
$attributes[$resourceClass] = $this->modelMetadata->getAttributes($model);
73+
$relations[$resourceClass] = $this->modelMetadata->getRelations($model);
74+
}
75+
76+
$directory = \dirname($path);
77+
if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) {
78+
$this->error(\sprintf('Unable to create directory "%s".', $directory));
79+
80+
return self::FAILURE;
81+
}
82+
83+
// Write to a temporary file then rename so a concurrent boot never reads a half-written dump.
84+
$payload = serialize(['attributes' => $attributes, 'relations' => $relations]);
85+
$temporaryPath = $path.'.'.getmypid().'.tmp';
86+
if (false === file_put_contents($temporaryPath, $payload) || !rename($temporaryPath, $path)) {
87+
@unlink($temporaryPath);
88+
$this->error(\sprintf('Unable to write the metadata dump to "%s".', $path));
89+
90+
return self::FAILURE;
91+
}
92+
93+
$this->info(\sprintf('Dumped metadata for %d model(s) to "%s".', \count($attributes), $path));
94+
95+
return self::SUCCESS;
96+
}
97+
}

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,17 @@ final class ModelMetadata
5656
'morphedByMany',
5757
];
5858

59-
public function __construct(private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter())
60-
{
59+
/**
60+
* @param array<class-string, array<string, mixed>> $attributes seeds the attribute cache, e.g. from a dump produced by api-platform:metadata:dump, so the app can boot without a database
61+
* @param array<class-string, array<string, mixed>> $relations seeds the relation cache for the same reason
62+
*/
63+
public function __construct(
64+
private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter(),
65+
array $attributes = [],
66+
array $relations = [],
67+
) {
68+
$this->attributesLocalCache = $attributes;
69+
$this->relationsLocalCache = $relations;
6170
}
6271

6372
/**
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Tests\Console;
15+
16+
use ApiPlatform\Laravel\Console\DumpMetadataCommand;
17+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
19+
use ApiPlatform\Metadata\Resource\ResourceNameCollection;
20+
use Illuminate\Console\Command;
21+
use Illuminate\Testing\PendingCommand;
22+
use Orchestra\Testbench\Concerns\WithWorkbench;
23+
use Orchestra\Testbench\TestCase;
24+
use Workbench\App\Models\Author;
25+
use Workbench\App\Models\Book;
26+
27+
class DumpMetadataCommandTest extends TestCase
28+
{
29+
use WithWorkbench;
30+
31+
private string $dumpPath;
32+
33+
protected function setUp(): void
34+
{
35+
parent::setUp();
36+
37+
$this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_dump_cmd_').'.meta';
38+
@unlink($this->dumpPath);
39+
}
40+
41+
protected function tearDown(): void
42+
{
43+
if (is_file($this->dumpPath)) {
44+
unlink($this->dumpPath);
45+
}
46+
47+
parent::tearDown();
48+
}
49+
50+
public function testItDumpsTheSeededModelMetadataToTheGivenFile(): void
51+
{
52+
$bookAttributes = ['name' => ['name' => 'name', 'type' => 'string']];
53+
$bookRelations = ['author' => ['name' => 'author', 'related' => Author::class]];
54+
$authorAttributes = ['id' => ['name' => 'id', 'type' => 'integer']];
55+
$authorRelations = [];
56+
57+
$this->seedCommandModelMetadata(
58+
attributes: [Book::class => $bookAttributes, Author::class => $authorAttributes],
59+
relations: [Book::class => $bookRelations, Author::class => $authorRelations],
60+
);
61+
$this->stubResourceClasses([Book::class, Author::class]);
62+
63+
$this->runDump()
64+
->expectsOutputToContain('Dumped metadata for 2 model(s)')
65+
->assertExitCode(Command::SUCCESS);
66+
67+
$this->assertFileExists($this->dumpPath);
68+
69+
$dumped = $this->readDump();
70+
71+
$this->assertSame(['attributes', 'relations'], array_keys($dumped));
72+
$this->assertSame($bookAttributes, $dumped['attributes'][Book::class]);
73+
$this->assertSame($authorAttributes, $dumped['attributes'][Author::class]);
74+
$this->assertSame($bookRelations, $dumped['relations'][Book::class]);
75+
$this->assertSame($authorRelations, $dumped['relations'][Author::class]);
76+
}
77+
78+
public function testItSkipsResourceClassesThatAreNotEloquentModels(): void
79+
{
80+
$bookAttributes = ['name' => ['name' => 'name', 'type' => 'string']];
81+
82+
$this->seedCommandModelMetadata(
83+
attributes: [Book::class => $bookAttributes],
84+
relations: [Book::class => []],
85+
);
86+
$this->stubResourceClasses([Book::class, 'App\\Resource\\NotAModel', \DateTimeImmutable::class]);
87+
88+
$this->runDump()
89+
->expectsOutputToContain('Dumped metadata for 1 model(s)')
90+
->assertExitCode(Command::SUCCESS);
91+
92+
$dumped = $this->readDump();
93+
94+
$this->assertSame([Book::class], array_keys($dumped['attributes']));
95+
$this->assertSame([Book::class], array_keys($dumped['relations']));
96+
}
97+
98+
public function testItFailsWhenNoPathIsResolvable(): void
99+
{
100+
$this->app['config']->set('api-platform.metadata_dump', null);
101+
$this->stubResourceClasses([]);
102+
103+
$command = $this->artisan('api-platform:metadata:dump');
104+
if (!$command instanceof PendingCommand) {
105+
$this->fail('artisan() did not return a PendingCommand.');
106+
}
107+
108+
$command
109+
->expectsOutputToContain('No dump path configured')
110+
->assertExitCode(Command::FAILURE);
111+
}
112+
113+
/**
114+
* @param array<class-string, array<string, mixed>> $attributes
115+
* @param array<class-string, array<string, mixed>> $relations
116+
*/
117+
private function seedCommandModelMetadata(array $attributes, array $relations): void
118+
{
119+
$this->app->when(DumpMetadataCommand::class)
120+
->needs(ModelMetadata::class)
121+
->give(static fn (): ModelMetadata => new ModelMetadata(attributes: $attributes, relations: $relations));
122+
}
123+
124+
/**
125+
* @param list<class-string> $classes
126+
*/
127+
private function stubResourceClasses(array $classes): void
128+
{
129+
$nameFactory = $this->createStub(ResourceNameCollectionFactoryInterface::class);
130+
$nameFactory->method('create')->willReturn(new ResourceNameCollection($classes));
131+
132+
$this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory);
133+
}
134+
135+
private function runDump(): PendingCommand
136+
{
137+
$command = $this->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath]);
138+
if (!$command instanceof PendingCommand) {
139+
$this->fail('artisan() did not return a PendingCommand.');
140+
}
141+
142+
return $command;
143+
}
144+
145+
/**
146+
* @return array{attributes: array<class-string, mixed>, relations: array<class-string, mixed>}
147+
*/
148+
private function readDump(): array
149+
{
150+
$contents = file_get_contents($this->dumpPath);
151+
if (false === $contents) {
152+
$this->fail(\sprintf('Unable to read the dump file "%s".', $this->dumpPath));
153+
}
154+
155+
$dumped = unserialize($contents, ['allowed_classes' => false]);
156+
if (!\is_array($dumped)) {
157+
$this->fail('The dump file did not contain an array.');
158+
}
159+
160+
return $dumped;
161+
}
162+
}

0 commit comments

Comments
 (0)