Skip to content

Commit df9fbad

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 8586a80 commit df9fbad

9 files changed

Lines changed: 605 additions & 19 deletions

File tree

src/Laravel/ApiPlatformProvider.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory;
8686
use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyNameCollectionMetadataFactory;
8787
use ApiPlatform\Laravel\Eloquent\Metadata\IdentifiersExtractor as EloquentIdentifiersExtractor;
88+
use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint;
8889
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
8990
use ApiPlatform\Laravel\Eloquent\Metadata\ResourceClassResolver as EloquentResourceClassResolver;
9091
use ApiPlatform\Laravel\Eloquent\PropertyAccess\PropertyAccessor as EloquentPropertyAccessor;
@@ -241,10 +242,38 @@ public function register(): void
241242
);
242243
});
243244

244-
$this->app->singleton(ModelMetadata::class, static function () {
245+
// Serve the Eloquent model metadata from a dumped file so the app can boot without a live
246+
// database. Skipped when APP_DEBUG is true so local development always introspects fresh.
247+
// The dump holds only attribute/relation arrays (plain scalars and class-strings), so it is
248+
// read back with allowed_classes disabled. The dump command gets a live, unseeded instance
249+
// (contextual binding below) so it never reads back its own dump.
250+
$this->app->singleton(ModelMetadata::class, static function (Application $app) {
251+
/** @var ConfigRepository $config */
252+
$config = $app['config'];
253+
$dumpPath = $config->get('api-platform.metadata_dump');
254+
255+
if (true !== $config->get('app.debug') && \is_string($dumpPath) && is_file($dumpPath)) {
256+
$contents = file_get_contents($dumpPath);
257+
if (false !== $contents) {
258+
$data = @unserialize($contents, ['allowed_classes' => false]);
259+
if (\is_array($data) && \is_array($data['attributes'] ?? null) && \is_array($data['relations'] ?? null)) {
260+
$fingerprint = MetadataDumpFingerprint::fromMigrations($app->databasePath('migrations'));
261+
if (($data['fingerprint'] ?? null) !== $fingerprint) {
262+
$app['log']->warning('The API Platform metadata dump is stale: migrations have changed since it was generated. Re-run "php artisan api-platform:metadata:dump".');
263+
}
264+
265+
return new ModelMetadata(attributes: $data['attributes'], relations: $data['relations']);
266+
}
267+
}
268+
}
269+
245270
return new ModelMetadata();
246271
});
247272

273+
$this->app->when(Console\DumpMetadataCommand::class)
274+
->needs(ModelMetadata::class)
275+
->give(static fn () => new ModelMetadata());
276+
248277
$this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class);
249278
$this->app->singleton(ClassMetadataFactory::class, static function (Application $app) {
250279
/** @var ConfigRepository */
@@ -1110,6 +1139,7 @@ public function register(): void
11101139
if ($this->app->runningInConsole()) {
11111140
$this->commands([
11121141
Console\InstallCommand::class,
1142+
Console\DumpMetadataCommand::class,
11131143
Console\Maker\MakeStateProcessorCommand::class,
11141144
Console\Maker\MakeStateProviderCommand::class,
11151145
Console\Maker\MakeFilterCommand::class,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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\MetadataDumpFingerprint;
17+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
19+
use Illuminate\Console\Command;
20+
use Illuminate\Database\Eloquent\Model;
21+
use Symfony\Component\Console\Attribute\AsCommand;
22+
23+
#[AsCommand(name: 'api-platform:metadata:dump')]
24+
final class DumpMetadataCommand extends Command
25+
{
26+
/**
27+
* @var string
28+
*/
29+
protected $signature = 'api-platform:metadata:dump {--path= : Where to write the dumped metadata file (defaults to the api-platform.metadata_dump config value)}';
30+
31+
/**
32+
* @var string
33+
*/
34+
protected $description = 'Dump the Eloquent model metadata to a file so the app can boot without hitting the database';
35+
36+
public function __construct(
37+
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
38+
private readonly ModelMetadata $modelMetadata,
39+
) {
40+
parent::__construct();
41+
}
42+
43+
public function handle(): int
44+
{
45+
$path = $this->option('path');
46+
if (null === $path || '' === $path) {
47+
$path = config('api-platform.metadata_dump');
48+
}
49+
50+
if (!\is_string($path) || '' === $path) {
51+
$this->error('No dump path configured. Pass --path or set the "api-platform.metadata_dump" config value.');
52+
53+
return self::FAILURE;
54+
}
55+
56+
// This command is bound to a live ModelMetadata (contextual binding) so introspection reads
57+
// the database rather than a previously dumped, possibly stale, cache.
58+
$attributes = [];
59+
$relations = [];
60+
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
61+
try {
62+
$model = (new \ReflectionClass($resourceClass))->newInstanceWithoutConstructor();
63+
} catch (\ReflectionException) {
64+
continue;
65+
}
66+
67+
if (!$model instanceof Model) {
68+
continue;
69+
}
70+
71+
$attributes[$resourceClass] = $this->modelMetadata->getAttributes($model);
72+
$relations[$resourceClass] = $this->modelMetadata->getRelations($model);
73+
}
74+
75+
$directory = \dirname($path);
76+
if (!is_dir($directory) && !mkdir($directory, 0o755, true) && !is_dir($directory)) {
77+
$this->error(\sprintf('Unable to create directory "%s".', $directory));
78+
79+
return self::FAILURE;
80+
}
81+
82+
// Write to a temporary file then rename so a concurrent boot never reads a half-written dump.
83+
$payload = serialize([
84+
'fingerprint' => MetadataDumpFingerprint::fromMigrations($this->laravel->databasePath('migrations')),
85+
'attributes' => $attributes,
86+
'relations' => $relations,
87+
]);
88+
$temporaryPath = $path.'.'.getmypid().'.tmp';
89+
if (false === file_put_contents($temporaryPath, $payload) || !rename($temporaryPath, $path)) {
90+
@unlink($temporaryPath);
91+
$this->error(\sprintf('Unable to write the metadata dump to "%s".', $path));
92+
93+
return self::FAILURE;
94+
}
95+
96+
$this->info(\sprintf('Dumped metadata for %d model(s) to "%s".', \count($attributes), $path));
97+
98+
return self::SUCCESS;
99+
}
100+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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\Eloquent\Metadata;
15+
16+
/**
17+
* Fingerprints the migration files so a stale metadata dump (migrations changed since it was
18+
* generated) can be detected at boot without reading the database, which would defeat the dump.
19+
*
20+
* It hashes file names, modification times and sizes only: a manual schema change made outside the
21+
* migration files is not detected.
22+
*
23+
* @internal
24+
*/
25+
final class MetadataDumpFingerprint
26+
{
27+
public static function fromMigrations(string $migrationsPath): string
28+
{
29+
$files = glob($migrationsPath.'/*.php') ?: [];
30+
sort($files);
31+
32+
$hash = hash_init('xxh128');
33+
foreach ($files as $file) {
34+
hash_update($hash, $file.'|'.filemtime($file).'|'.filesize($file));
35+
}
36+
37+
return hash_final($hash);
38+
}
39+
}

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,6 @@
2727
*/
2828
final class ModelMetadata
2929
{
30-
/**
31-
* @var array<class-string, array<string, mixed>>
32-
*/
33-
private $attributesLocalCache = [];
34-
35-
/**
36-
* @var array<class-string, array<string, mixed>>
37-
*/
38-
private $relationsLocalCache = [];
39-
4030
/**
4131
* The methods that can be called in a model to indicate a relation.
4232
*
@@ -56,8 +46,15 @@ final class ModelMetadata
5646
'morphedByMany',
5747
];
5848

59-
public function __construct(private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter())
60-
{
49+
/**
50+
* @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
51+
* @param array<class-string, array<string, mixed>> $relations seeds the relation cache for the same reason
52+
*/
53+
public function __construct(
54+
private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter(),
55+
private array $attributes = [],
56+
private array $relations = [],
57+
) {
6158
}
6259

6360
/**
@@ -67,8 +64,8 @@ public function __construct(private NameConverterInterface $relationNameConverte
6764
*/
6865
public function getAttributes(Model $model): array
6966
{
70-
if (isset($this->attributesLocalCache[$model::class])) {
71-
return $this->attributesLocalCache[$model::class];
67+
if (isset($this->attributes[$model::class])) {
68+
return $this->attributes[$model::class];
7269
}
7370

7471
$connection = $model->getConnection();
@@ -112,7 +109,7 @@ public function getAttributes(Model $model): array
112109
return $result;
113110
}
114111

115-
return $this->attributesLocalCache[$model::class] = $result;
112+
return $this->attributes[$model::class] = $result;
116113
}
117114

118115
/**
@@ -194,8 +191,8 @@ private function getVirtualAttributes(Model $model, array $columns): array
194191
*/
195192
public function getRelations(Model $model): array
196193
{
197-
if (isset($this->relationsLocalCache[$model::class])) {
198-
return $this->relationsLocalCache[$model::class];
194+
if (isset($this->relations[$model::class])) {
195+
return $this->relations[$model::class];
199196
}
200197

201198
$relations = [];
@@ -254,7 +251,7 @@ public function getRelations(Model $model): array
254251
];
255252
}
256253

257-
return $this->relationsLocalCache[$model::class] = $relations;
254+
return $this->relations[$model::class] = $relations;
258255
}
259256

260257
/**

0 commit comments

Comments
 (0)