From 873e5d8e2a26ed5c254545987d79dcc4116606d8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 16 Jun 2026 22:08:34 +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 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. --- src/Laravel/ApiPlatformProvider.php | 32 +++- src/Laravel/Console/DumpMetadataCommand.php | 100 +++++++++++ .../Metadata/MetadataDumpFingerprint.php | 39 +++++ .../Eloquent/Metadata/ModelMetadata.php | 33 ++-- .../Tests/Console/DumpMetadataCommandTest.php | 163 ++++++++++++++++++ .../Tests/Metadata/DumpedMetadataBootTest.php | 113 ++++++++++++ .../Metadata/MetadataDumpFingerprintTest.php | 96 +++++++++++ .../Tests/Unit/Metadata/ModelMetadataTest.php | 43 +++++ src/Laravel/config/api-platform.php | 8 + 9 files changed, 608 insertions(+), 19 deletions(-) create mode 100644 src/Laravel/Console/DumpMetadataCommand.php create mode 100644 src/Laravel/Eloquent/Metadata/MetadataDumpFingerprint.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/MetadataDumpFingerprintTest.php create mode 100644 src/Laravel/Tests/Unit/Metadata/ModelMetadataTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 889f7703b49..f3b4f0f33a4 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -85,6 +85,7 @@ use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory; use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyNameCollectionMetadataFactory; use ApiPlatform\Laravel\Eloquent\Metadata\IdentifiersExtractor as EloquentIdentifiersExtractor; +use ApiPlatform\Laravel\Eloquent\Metadata\MetadataDumpFingerprint; use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; use ApiPlatform\Laravel\Eloquent\Metadata\ResourceClassResolver as EloquentResourceClassResolver; use ApiPlatform\Laravel\Eloquent\PropertyAccess\PropertyAccessor as EloquentPropertyAccessor; @@ -241,10 +242,38 @@ public function register(): void ); }); - $this->app->singleton(ModelMetadata::class, static function () { + // Serve the Eloquent model metadata from a dumped file so the app can boot without a live + // database. Skipped when APP_DEBUG is true so local development always introspects fresh. + // The dump holds only attribute/relation arrays (plain scalars and class-strings), so it is + // read back with allowed_classes disabled. The dump command gets a live, unseeded instance + // (contextual binding below) so it never reads back its own dump. + $this->app->singleton(ModelMetadata::class, static function (Application $app) { + /** @var ConfigRepository $config */ + $config = $app['config']; + $dumpPath = $config->get('api-platform.metadata_dump'); + + if (true !== $config->get('app.debug') && \is_string($dumpPath) && is_file($dumpPath)) { + $contents = file_get_contents($dumpPath); + if (false !== $contents) { + $data = @unserialize($contents, ['allowed_classes' => false]); + if (\is_array($data) && \is_array($data['attributes'] ?? null) && \is_array($data['relations'] ?? null)) { + $fingerprint = MetadataDumpFingerprint::fromMigrations($app->databasePath('migrations')); + if (($data['fingerprint'] ?? null) !== $fingerprint) { + $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".'); + } + + return new ModelMetadata(attributes: $data['attributes'], relations: $data['relations']); + } + } + } + return new ModelMetadata(); }); + $this->app->when(Console\DumpMetadataCommand::class) + ->needs(ModelMetadata::class) + ->give(static fn () => new ModelMetadata()); + $this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class); $this->app->singleton(ClassMetadataFactory::class, static function (Application $app) { /** @var ConfigRepository */ @@ -1110,6 +1139,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..e8ba05aafdd --- /dev/null +++ b/src/Laravel/Console/DumpMetadataCommand.php @@ -0,0 +1,100 @@ + + * + * 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\Eloquent\Metadata\MetadataDumpFingerprint; +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Model; +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 Eloquent model metadata to a file so the app can boot without hitting the database'; + + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ModelMetadata $modelMetadata, + ) { + parent::__construct(); + } + + public function handle(): int + { + $path = $this->option('path'); + if (null === $path || '' === $path) { + $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; + } + + // This command is bound to a live ModelMetadata (contextual binding) so introspection reads + // the database rather than a previously dumped, possibly stale, cache. + $attributes = []; + $relations = []; + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + try { + $model = (new \ReflectionClass($resourceClass))->newInstanceWithoutConstructor(); + } catch (\ReflectionException) { + continue; + } + + if (!$model instanceof Model) { + continue; + } + + $attributes[$resourceClass] = $this->modelMetadata->getAttributes($model); + $relations[$resourceClass] = $this->modelMetadata->getRelations($model); + } + + $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; + } + + // Write to a temporary file then rename so a concurrent boot never reads a half-written dump. + $payload = serialize([ + 'fingerprint' => MetadataDumpFingerprint::fromMigrations($this->laravel->databasePath('migrations')), + 'attributes' => $attributes, + 'relations' => $relations, + ]); + $temporaryPath = $path.'.'.getmypid().'.tmp'; + if (false === file_put_contents($temporaryPath, $payload) || !rename($temporaryPath, $path)) { + @unlink($temporaryPath); + $this->error(\sprintf('Unable to write the metadata dump to "%s".', $path)); + + return self::FAILURE; + } + + $this->info(\sprintf('Dumped metadata for %d model(s) to "%s".', \count($attributes), $path)); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/Eloquent/Metadata/MetadataDumpFingerprint.php b/src/Laravel/Eloquent/Metadata/MetadataDumpFingerprint.php new file mode 100644 index 00000000000..2be611c2ad4 --- /dev/null +++ b/src/Laravel/Eloquent/Metadata/MetadataDumpFingerprint.php @@ -0,0 +1,39 @@ + + * + * 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\Eloquent\Metadata; + +/** + * Fingerprints the migration files so a stale metadata dump (migrations changed since it was + * generated) can be detected at boot without reading the database, which would defeat the dump. + * + * It hashes file names, modification times and sizes only: a manual schema change made outside the + * migration files is not detected. + * + * @internal + */ +final class MetadataDumpFingerprint +{ + public static function fromMigrations(string $migrationsPath): string + { + $files = glob($migrationsPath.'/*.php') ?: []; + sort($files); + + $hash = hash_init('xxh128'); + foreach ($files as $file) { + hash_update($hash, $file.'|'.filemtime($file).'|'.filesize($file)); + } + + return hash_final($hash); + } +} diff --git a/src/Laravel/Eloquent/Metadata/ModelMetadata.php b/src/Laravel/Eloquent/Metadata/ModelMetadata.php index a3d23d4f5b7..ae89498df60 100644 --- a/src/Laravel/Eloquent/Metadata/ModelMetadata.php +++ b/src/Laravel/Eloquent/Metadata/ModelMetadata.php @@ -27,16 +27,6 @@ */ final class ModelMetadata { - /** - * @var array> - */ - private $attributesLocalCache = []; - - /** - * @var array> - */ - private $relationsLocalCache = []; - /** * The methods that can be called in a model to indicate a relation. * @@ -56,8 +46,15 @@ final class ModelMetadata 'morphedByMany', ]; - public function __construct(private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter()) - { + /** + * @param array> $attributes seeds the attribute cache, e.g. from a dump produced by api-platform:metadata:dump, so the app can boot without a database + * @param array> $relations seeds the relation cache for the same reason + */ + public function __construct( + private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter(), + private array $attributes = [], + private array $relations = [], + ) { } /** @@ -67,8 +64,8 @@ public function __construct(private NameConverterInterface $relationNameConverte */ public function getAttributes(Model $model): array { - if (isset($this->attributesLocalCache[$model::class])) { - return $this->attributesLocalCache[$model::class]; + if (isset($this->attributes[$model::class])) { + return $this->attributes[$model::class]; } $connection = $model->getConnection(); @@ -112,7 +109,7 @@ public function getAttributes(Model $model): array return $result; } - return $this->attributesLocalCache[$model::class] = $result; + return $this->attributes[$model::class] = $result; } /** @@ -194,8 +191,8 @@ private function getVirtualAttributes(Model $model, array $columns): array */ public function getRelations(Model $model): array { - if (isset($this->relationsLocalCache[$model::class])) { - return $this->relationsLocalCache[$model::class]; + if (isset($this->relations[$model::class])) { + return $this->relations[$model::class]; } $relations = []; @@ -254,7 +251,7 @@ public function getRelations(Model $model): array ]; } - return $this->relationsLocalCache[$model::class] = $relations; + return $this->relations[$model::class] = $relations; } /** diff --git a/src/Laravel/Tests/Console/DumpMetadataCommandTest.php b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php new file mode 100644 index 00000000000..1506bc75c39 --- /dev/null +++ b/src/Laravel/Tests/Console/DumpMetadataCommandTest.php @@ -0,0 +1,163 @@ + + * + * 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\Console\DumpMetadataCommand; +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use Illuminate\Console\Command; +use Illuminate\Testing\PendingCommand; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Author; +use Workbench\App\Models\Book; + +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 testItDumpsTheSeededModelMetadataToTheGivenFile(): void + { + $bookAttributes = ['name' => ['name' => 'name', 'type' => 'string']]; + $bookRelations = ['author' => ['name' => 'author', 'related' => Author::class]]; + $authorAttributes = ['id' => ['name' => 'id', 'type' => 'integer']]; + $authorRelations = []; + + $this->seedCommandModelMetadata( + attributes: [Book::class => $bookAttributes, Author::class => $authorAttributes], + relations: [Book::class => $bookRelations, Author::class => $authorRelations], + ); + $this->stubResourceClasses([Book::class, Author::class]); + + $this->runDump() + ->expectsOutputToContain('Dumped metadata for 2 model(s)') + ->assertExitCode(Command::SUCCESS); + + $this->assertFileExists($this->dumpPath); + + $dumped = $this->readDump(); + + $this->assertSame(['fingerprint', 'attributes', 'relations'], array_keys($dumped)); + $this->assertIsString($dumped['fingerprint']); + $this->assertSame($bookAttributes, $dumped['attributes'][Book::class]); + $this->assertSame($authorAttributes, $dumped['attributes'][Author::class]); + $this->assertSame($bookRelations, $dumped['relations'][Book::class]); + $this->assertSame($authorRelations, $dumped['relations'][Author::class]); + } + + public function testItSkipsResourceClassesThatAreNotEloquentModels(): void + { + $bookAttributes = ['name' => ['name' => 'name', 'type' => 'string']]; + + $this->seedCommandModelMetadata( + attributes: [Book::class => $bookAttributes], + relations: [Book::class => []], + ); + $this->stubResourceClasses([Book::class, 'App\\Resource\\NotAModel', \DateTimeImmutable::class]); + + $this->runDump() + ->expectsOutputToContain('Dumped metadata for 1 model(s)') + ->assertExitCode(Command::SUCCESS); + + $dumped = $this->readDump(); + + $this->assertSame([Book::class], array_keys($dumped['attributes'])); + $this->assertSame([Book::class], array_keys($dumped['relations'])); + } + + public function testItFailsWhenNoPathIsResolvable(): void + { + $this->app['config']->set('api-platform.metadata_dump', null); + $this->stubResourceClasses([]); + + $command = $this->artisan('api-platform:metadata:dump'); + if (!$command instanceof PendingCommand) { + $this->fail('artisan() did not return a PendingCommand.'); + } + + $command + ->expectsOutputToContain('No dump path configured') + ->assertExitCode(Command::FAILURE); + } + + /** + * @param array> $attributes + * @param array> $relations + */ + private function seedCommandModelMetadata(array $attributes, array $relations): void + { + $this->app->when(DumpMetadataCommand::class) + ->needs(ModelMetadata::class) + ->give(static fn (): ModelMetadata => new ModelMetadata(attributes: $attributes, relations: $relations)); + } + + /** + * @param list $classes + */ + private function stubResourceClasses(array $classes): void + { + $nameFactory = $this->createStub(ResourceNameCollectionFactoryInterface::class); + $nameFactory->method('create')->willReturn(new ResourceNameCollection($classes)); + + $this->app->instance(ResourceNameCollectionFactoryInterface::class, $nameFactory); + } + + private function runDump(): PendingCommand + { + $command = $this->artisan('api-platform:metadata:dump', ['--path' => $this->dumpPath]); + if (!$command instanceof PendingCommand) { + $this->fail('artisan() did not return a PendingCommand.'); + } + + return $command; + } + + /** + * @return array{fingerprint: string, attributes: array, relations: array} + */ + private function readDump(): array + { + $contents = file_get_contents($this->dumpPath); + if (false === $contents) { + $this->fail(\sprintf('Unable to read the dump file "%s".', $this->dumpPath)); + } + + $dumped = unserialize($contents, ['allowed_classes' => false]); + if (!\is_array($dumped)) { + $this->fail('The dump file did not contain an array.'); + } + + return $dumped; + } +} diff --git a/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php b/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php new file mode 100644 index 00000000000..8267d1f92e2 --- /dev/null +++ b/src/Laravel/Tests/Metadata/DumpedMetadataBootTest.php @@ -0,0 +1,113 @@ + + * + * 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\Eloquent\Metadata\MetadataDumpFingerprint; +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use Illuminate\Support\Facades\Log; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Book; + +class DumpedMetadataBootTest extends TestCase +{ + use WithWorkbench; + + private const CANNED_ATTRIBUTES = [ + 'name' => ['name' => 'name', 'type' => 'string', 'nullable' => false], + ]; + + private const CANNED_RELATIONS = [ + 'author' => ['name' => 'author', 'method_name' => 'author', 'related' => 'Workbench\\App\\Models\\Author'], + ]; + + private string $dumpPath; + + protected function setUp(): void + { + $this->dumpPath = tempnam(sys_get_temp_dir(), 'apip_boot_dump_').'.meta'; + + file_put_contents($this->dumpPath, serialize([ + 'attributes' => [Book::class => self::CANNED_ATTRIBUTES], + 'relations' => [Book::class => self::CANNED_RELATIONS], + ])); + + 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 testItServesModelMetadataFromTheDumpWithoutHittingTheDatabase(): void + { + $modelMetadata = $this->app->make(ModelMetadata::class); + + $book = (new \ReflectionClass(Book::class))->newInstanceWithoutConstructor(); + + $this->assertSame(self::CANNED_ATTRIBUTES, $modelMetadata->getAttributes($book)); + $this->assertSame(self::CANNED_RELATIONS, $modelMetadata->getRelations($book)); + } + + public function testItWarnsWhenTheDumpFingerprintIsStale(): void + { + // The canned dump written in setUp() carries no fingerprint, so it never matches the + // current migrations and must be reported as stale. + Log::spy(); + $this->app->forgetInstance(ModelMetadata::class); + + $this->app->make(ModelMetadata::class); + + Log::shouldHaveReceived('warning')->withArgs(static fn (string $message): bool => str_contains($message, 'stale'))->once(); + } + + public function testItDoesNotWarnWhenTheFingerprintMatches(): void + { + file_put_contents($this->dumpPath, serialize([ + 'fingerprint' => MetadataDumpFingerprint::fromMigrations($this->app->databasePath('migrations')), + 'attributes' => [Book::class => self::CANNED_ATTRIBUTES], + 'relations' => [Book::class => self::CANNED_RELATIONS], + ])); + + Log::spy(); + $this->app->forgetInstance(ModelMetadata::class); + + $this->app->make(ModelMetadata::class); + + Log::shouldNotHaveReceived('warning'); + } + + public function testItIsNotSeededWhenDebugIsEnabled(): void + { + $this->app['config']->set('app.debug', true); + $this->app->forgetInstance(ModelMetadata::class); + + $modelMetadata = $this->app->make(ModelMetadata::class); + + $reflection = new \ReflectionProperty(ModelMetadata::class, 'attributes'); + + $this->assertSame([], $reflection->getValue($modelMetadata), 'A debug boot must return an unseeded ModelMetadata that introspects the database.'); + } +} diff --git a/src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php b/src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php new file mode 100644 index 00000000000..d5083e2b73b --- /dev/null +++ b/src/Laravel/Tests/Unit/Metadata/MetadataDumpFingerprintTest.php @@ -0,0 +1,96 @@ + + * + * 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\Eloquent\Metadata\MetadataDumpFingerprint; +use PHPUnit\Framework\TestCase; + +class MetadataDumpFingerprintTest extends TestCase +{ + private string $migrationsPath; + + protected function setUp(): void + { + $this->migrationsPath = sys_get_temp_dir().'/apip_fingerprint_'.getmypid(); + if (!is_dir($this->migrationsPath)) { + mkdir($this->migrationsPath, 0o755, true); + } + + foreach (glob($this->migrationsPath.'/*.php') ?: [] as $file) { + unlink($file); + } + } + + protected function tearDown(): void + { + foreach (glob($this->migrationsPath.'/*.php') ?: [] as $file) { + unlink($file); + } + + if (is_dir($this->migrationsPath)) { + rmdir($this->migrationsPath); + } + } + + public function testItIsStableForUnchangedMigrations(): void + { + $this->writeMigration('2024_01_01_000000_create_books_table.php', 1_700_000_000); + + $first = MetadataDumpFingerprint::fromMigrations($this->migrationsPath); + $second = MetadataDumpFingerprint::fromMigrations($this->migrationsPath); + + $this->assertSame($first, $second); + } + + public function testItChangesWhenAMigrationIsAdded(): void + { + $this->writeMigration('2024_01_01_000000_create_books_table.php', 1_700_000_000); + $before = MetadataDumpFingerprint::fromMigrations($this->migrationsPath); + + $this->writeMigration('2024_02_02_000000_create_authors_table.php', 1_700_000_100); + + $this->assertNotSame($before, MetadataDumpFingerprint::fromMigrations($this->migrationsPath)); + } + + public function testItChangesWhenAMigrationIsTouched(): void + { + $file = $this->writeMigration('2024_01_01_000000_create_books_table.php', 1_700_000_000); + $before = MetadataDumpFingerprint::fromMigrations($this->migrationsPath); + + touch($file, 1_700_999_999); + // The first fingerprint call already stat()ed the file, so its mtime is cached for this + // process; clear it so the second call reads the value set by touch(). + clearstatcache(true, $file); + + $this->assertNotSame($before, MetadataDumpFingerprint::fromMigrations($this->migrationsPath)); + } + + public function testItIsEmptyHashForNoMigrations(): void + { + $this->assertNotSame('', MetadataDumpFingerprint::fromMigrations($this->migrationsPath)); + $this->assertSame( + MetadataDumpFingerprint::fromMigrations($this->migrationsPath), + MetadataDumpFingerprint::fromMigrations($this->migrationsPath.'/missing'), + ); + } + + private function writeMigration(string $name, int $mtime): string + { + $path = $this->migrationsPath.'/'.$name; + file_put_contents($path, ' + * + * 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\Eloquent\Metadata\ModelMetadata; +use PHPUnit\Framework\TestCase; +use Workbench\App\Models\Book; + +class ModelMetadataTest extends TestCase +{ + public function testSeededAttributesAreReturnedWithoutDatabaseIntrospection(): void + { + $attributes = ['name' => ['name' => 'name', 'type' => 'string']]; + + $modelMetadata = new ModelMetadata(attributes: [Book::class => $attributes]); + + $book = (new \ReflectionClass(Book::class))->newInstanceWithoutConstructor(); + + $this->assertSame($attributes, $modelMetadata->getAttributes($book)); + } + + public function testSeededRelationsAreReturnedWithoutDatabaseIntrospection(): void + { + $relations = ['author' => ['name' => 'author', 'method_name' => 'author', 'related' => 'Workbench\\App\\Models\\Author']]; + + $modelMetadata = new ModelMetadata(relations: [Book::class => $relations]); + + $book = (new \ReflectionClass(Book::class))->newInstanceWithoutConstructor(); + + $this->assertSame($relations, $modelMetadata->getRelations($book)); + } +} diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 38e0de76e9f..15f23e34cc3 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -178,6 +178,14 @@ // we recommend using "file" or "acpu" 'cache' => 'file', + // Path to an Eloquent model metadata file produced by `php artisan api-platform:metadata:dump`. + // When set (and APP_DEBUG is false), the model attributes and relations are 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, and re-run the command when your models change. Leave + // null to disable. + 'metadata_dump' => null, + // MCP (Model Context Protocol) configuration 'mcp' => [ 'enabled' => true,