diff --git a/composer.json b/composer.json
index 41854ccc2..d4a4027f6 100644
--- a/composer.json
+++ b/composer.json
@@ -16,29 +16,31 @@
}
],
"require" : {
- "php" : "^8.1",
- "illuminate/contracts" : "^9.30|^10.0",
- "phpdocumentor/type-resolver" : "^1.5",
- "spatie/laravel-package-tools" : "^1.9.0"
+ "php": "^8.1",
+ "illuminate/contracts": "^9.30|^10.0",
+ "phpdocumentor/type-resolver": "^1.5",
+ "spatie/laravel-package-tools": "^1.9.0",
+ "spatie/php-structure-discoverer": "^2.0"
},
"require-dev" : {
- "fakerphp/faker" : "^1.14",
- "friendsofphp/php-cs-fixer" : "^3.0",
- "inertiajs/inertia-laravel" : "^0.6.3",
- "nesbot/carbon" : "^2.63",
- "nette/php-generator" : "^3.5",
- "nunomaduro/larastan" : "^2.0",
- "orchestra/testbench" : "^7.6|^8.0",
- "pestphp/pest" : "^1.22",
- "pestphp/pest-plugin-laravel" : "^1.3",
- "phpbench/phpbench" : "^1.2",
- "phpstan/extension-installer" : "^1.1",
- "phpunit/phpunit" : "^9.3",
- "spatie/invade" : "^1.0",
- "spatie/laravel-typescript-transformer" : "^2.1.6",
- "spatie/pest-plugin-snapshots" : "^1.1",
- "spatie/phpunit-snapshot-assertions" : "^4.2",
- "spatie/test-time" : "^1.2"
+ "fakerphp/faker": "^1.14",
+ "friendsofphp/php-cs-fixer": "^3.0",
+ "inertiajs/inertia-laravel": "^0.6.3",
+ "mockery/mockery": "^1.6",
+ "nesbot/carbon": "^2.63",
+ "nette/php-generator": "^3.5",
+ "nunomaduro/larastan": "^2.0",
+ "orchestra/testbench": "^7.6|^8.0",
+ "pestphp/pest": "^1.22",
+ "pestphp/pest-plugin-laravel": "^1.3",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.1",
+ "phpunit/phpunit": "^9.3",
+ "spatie/invade": "^1.0",
+ "spatie/laravel-typescript-transformer": "^2.1.6",
+ "spatie/pest-plugin-snapshots": "^1.1",
+ "spatie/phpunit-snapshot-assertions": "^4.2",
+ "spatie/test-time": "^1.2"
},
"autoload" : {
"psr-4" : {
diff --git a/config/data.php b/config/data.php
index 2095a5d85..6a0c70862 100644
--- a/config/data.php
+++ b/config/data.php
@@ -1,14 +1,14 @@
DATE_ATOM,
- /*
+ /**
* Global transformers will take complex types and transform them into simple
* types.
*/
@@ -18,7 +18,7 @@
BackedEnum::class => Spatie\LaravelData\Transformers\EnumTransformer::class,
],
- /*
+ /**
* Global casts will cast values into complex types when creating a data
* object from simple types.
*/
@@ -27,7 +27,7 @@
BackedEnum::class => Spatie\LaravelData\Casts\EnumCast::class,
],
- /*
+ /**
* Rule inferrers can be configured here. They will automatically add
* validation rules to properties of a data object based upon
* the type of the property.
@@ -54,7 +54,7 @@
Spatie\LaravelData\Normalizers\JsonNormalizer::class,
],
- /*
+ /**
* Data objects can be wrapped into a key like 'data' when used as a resource,
* this key can be set globally here for all data objects. You can pass in
* `null` if you want to disable wrapping.
@@ -68,4 +68,23 @@
* which will only enable the caster locally.
*/
'var_dumper_caster_mode' => 'development',
+
+ /**
+ * It is possible to skip the PHP reflection analysis of data objects
+ * when running in production. This will speed up the package. You
+ * can configure where data objects are stored and which cache
+ * store should be used.
+ */
+ 'structure_caching' => [
+ 'directories' => [app_path('Data')],
+ 'cache' => [
+ 'store' => env('CACHE_DRIVER', 'file'),
+ 'prefix' => 'laravel-data',
+ ],
+ 'reflection_discovery' => [
+ 'enabled' => true,
+ 'base_path' => base_path(),
+ 'root_namespace' => null,
+ ],
+ ],
];
diff --git a/docs/advanced-usage/commands.md b/docs/advanced-usage/commands.md
index fa2dfc4b7..2786a9716 100644
--- a/docs/advanced-usage/commands.md
+++ b/docs/advanced-usage/commands.md
@@ -1,6 +1,6 @@
---
title: Commands
-weight: 15
+weight: 16
---
## make:data
diff --git a/docs/advanced-usage/performance.md b/docs/advanced-usage/performance.md
new file mode 100644
index 000000000..f113e93a5
--- /dev/null
+++ b/docs/advanced-usage/performance.md
@@ -0,0 +1,65 @@
+---
+title: Performance
+weight: 15
+---
+
+Laravel Data is a powerful package that leverages PHP reflection to infer as much information as possible. While this approach provides a lot of benefits, it does come with a minor performance overhead. This overhead is typically negligible during development, but it can become noticeable in a production environment with a large number of data objects.
+
+Fortunately, Laravel Data is designed to operate efficiently without relying on reflection. It achieves this by allowing you to cache the results of its complex analysis. This means that the performance cost is incurred only once, rather than on every request. By caching the analysis results before deploying your application to production, you ensure that a pre-analyzed, cached version of the data objects is used, significantly improving performance.
+
+## Caching
+
+Laravel Data provides a command to cache the analysis results of your data objects. This command will analyze all of your data objects and store the results in a Laravel cache of your choice:
+
+```
+php artisan data:cache-structures
+```
+
+That's it, the command will search for all the data objects in your application and cache the analysis results. Be sure to always run this command after creating or modifying a data object or when deploying your application to production.
+
+## Configuration
+
+The caching mechanism can be configured in the `data.php` config file. By default, the cache store is set to the default cache store of your application. You can change this to any other cache driver supported by Laravel. A prefix can also be set for the cache keys stored:
+
+```php
+'structure_caching' => [
+ 'cache' => [
+ 'store' => 'redis',
+ 'prefix' => 'laravel-data',
+ ],
+],
+```
+
+To find the data classes within your application, we're using the [php-structure-discoverer](https://github.com/spatie/php-structure-discoverer) package. This package allows you to configure the directories that will be searched for data objects. By default, the `app/data` directory is searched recursively. You can change this to any other directory or directories:
+
+```php
+'structure_caching' => [
+ 'directories' => [
+ 'app',
+ ],
+],
+```
+
+Structure discoverer uses reflection (enabled by default) or a PHP parser to find the data objects. You can disable the reflection based discovery and thus use the PHP parser discovery as such:
+
+```php
+'structure_caching' => [
+ 'reflection_discovery' => [
+ 'enabled' => false,
+ ],
+],
+```
+
+When using reflection discovery, the base directory and root namespace can be configured as such if you're using a non-standard directory structure or namespace
+
+```php
+'structure_caching' => [
+ 'reflection_discovery' => [
+ 'enabled' => true,
+ 'base_path' => base_path(),
+ 'root_namespace' => null,
+ ],
+],
+```
+
+You can read more about reflection discovery [here](https://github.com/spatie/php-structure-discoverer#parsers).
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 94c0e39be..84f177600 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -36,4 +36,7 @@
+
+
+
diff --git a/src/Commands/DataStructuresCacheCommand.php b/src/Commands/DataStructuresCacheCommand.php
new file mode 100644
index 000000000..6b5f93f3e
--- /dev/null
+++ b/src/Commands/DataStructuresCacheCommand.php
@@ -0,0 +1,53 @@
+components->info('Caching data structures...');
+
+ $dataClasses = DataClassFinder::fromConfig(config('data.structure_caching'))->classes();
+
+ $cachedDataConfig = CachedDataConfig::initialize($dataConfig);
+
+ $dataStructureCache->storeConfig($cachedDataConfig);
+
+ $progressBar = $this->output->createProgressBar(count($dataClasses));
+
+ foreach ($dataClasses as $dataClass) {
+ $dataStructureCache->storeDataClass(
+ DataClass::create(new ReflectionClass($dataClass))
+ );
+
+ $progressBar->advance();
+ }
+
+ $progressBar->finish();
+
+ $this->line(PHP_EOL);
+ $this->line('Cached '.count($dataClasses).' data classes');
+
+ if ($this->option('show-classes')) {
+ $this->table(
+ ['Data Class'],
+ array_map(fn (string $dataClass) => [$dataClass], $dataClasses)
+ );
+ }
+ }
+}
diff --git a/src/LaravelDataServiceProvider.php b/src/LaravelDataServiceProvider.php
index 015247628..276c676c4 100644
--- a/src/LaravelDataServiceProvider.php
+++ b/src/LaravelDataServiceProvider.php
@@ -3,7 +3,9 @@
namespace Spatie\LaravelData;
use Spatie\LaravelData\Commands\DataMakeCommand;
+use Spatie\LaravelData\Commands\DataStructuresCacheCommand;
use Spatie\LaravelData\Contracts\BaseData;
+use Spatie\LaravelData\Support\Caching\DataStructureCache;
use Spatie\LaravelData\Support\DataConfig;
use Spatie\LaravelData\Support\VarDumper\VarDumperManager;
use Spatie\LaravelPackageTools\Package;
@@ -16,14 +18,20 @@ public function configurePackage(Package $package): void
$package
->name('laravel-data')
->hasCommand(DataMakeCommand::class)
+ ->hasCommand(DataStructuresCacheCommand::class)
->hasConfigFile('data');
}
- public function packageRegistered()
+ public function packageRegistered(): void
{
+ $this->app->singleton(
+ DataStructureCache::class,
+ fn () => new DataStructureCache(config('data.structure_caching.cache'))
+ );
+
$this->app->singleton(
DataConfig::class,
- fn () => new DataConfig(config('data'))
+ fn () => $this->app->make(DataStructureCache::class)->getConfig() ?? new DataConfig(config('data'))
);
/** @psalm-suppress UndefinedInterfaceMethod */
@@ -39,7 +47,7 @@ public function packageRegistered()
});
}
- public function packageBooted()
+ public function packageBooted(): void
{
$enableVarDumperCaster = match (config('data.var_dumper_caster_mode')) {
'enabled' => true,
diff --git a/src/Resolvers/PartialsTreeFromRequestResolver.php b/src/Resolvers/PartialsTreeFromRequestResolver.php
index 657520ee6..46e81e9d2 100644
--- a/src/Resolvers/PartialsTreeFromRequestResolver.php
+++ b/src/Resolvers/PartialsTreeFromRequestResolver.php
@@ -33,23 +33,21 @@ public function execute(
$dataClass = $this->dataConfig->getDataClass($dataClass);
- $mapping = $dataClass->outputNameMapping->resolve();
-
$requestedIncludesTree = $this->partialsParser->execute(
$request->has('include') ? $this->arrayFromRequest($request, 'include') : [],
- $mapping
+ $dataClass->outputNameMapping
);
$requestedExcludesTree = $this->partialsParser->execute(
$request->has('exclude') ? $this->arrayFromRequest($request, 'exclude') : [],
- $mapping
+ $dataClass->outputNameMapping
);
$requestedOnlyTree = $this->partialsParser->execute(
$request->has('only') ? $this->arrayFromRequest($request, 'only') : [],
- $mapping
+ $dataClass->outputNameMapping
);
$requestedExceptTree = $this->partialsParser->execute(
$request->has('except') ? $this->arrayFromRequest($request, 'except') : [],
- $mapping
+ $dataClass->outputNameMapping
);
$allowedRequestIncludesTree = $this->allowedPartialsParser->execute('allowedRequestIncludes', $dataClass);
diff --git a/src/Support/Caching/CachedDataConfig.php b/src/Support/Caching/CachedDataConfig.php
new file mode 100644
index 000000000..9a3b527d7
--- /dev/null
+++ b/src/Support/Caching/CachedDataConfig.php
@@ -0,0 +1,49 @@
+ [],
+ 'transformers' => [],
+ 'casts' => [],
+ ]); // Ensure the parent object is constructed empty, todo v4: remove this and use a better constructor with factory
+ }
+
+ public function getDataClass(string $class): DataClass
+ {
+ return $this->cache?->getDataClass($class) ?? parent::getDataClass($class);
+ }
+
+ public function setCache(DataStructureCache $cache): self
+ {
+ $this->cache = $cache;
+
+ return $this;
+ }
+
+ public static function initialize(
+ DataConfig $dataConfig
+ ): self {
+ $cachedConfig = new self();
+
+ $cachedConfig->ruleInferrers = $dataConfig->ruleInferrers;
+ $cachedConfig->transformers = $dataConfig->transformers;
+ $cachedConfig->casts = $dataConfig->casts;
+
+ $cachedConfig->dataClasses = [];
+ $cachedConfig->resolvedDataPipelines = [];
+
+ $dataConfig->morphMap->merge($cachedConfig->morphMap);
+
+ return $cachedConfig;
+ }
+}
diff --git a/src/Support/Caching/DataClassFinder.php b/src/Support/Caching/DataClassFinder.php
new file mode 100644
index 000000000..e1397cdce
--- /dev/null
+++ b/src/Support/Caching/DataClassFinder.php
@@ -0,0 +1,42 @@
+ $directories
+ */
+ public function __construct(
+ protected array $directories,
+ protected bool $useReflection,
+ protected ?string $reflectionBasePath,
+ protected ?string $reflectionRootNamespace,
+ ) {
+ }
+
+ public function classes(): array
+ {
+ $discoverer = Discover::in(...$this->directories)
+ ->implementing(BaseData::class);
+
+ if ($this->useReflection) {
+ $discoverer->useReflection($this->reflectionBasePath, $this->reflectionRootNamespace);
+ }
+
+ return $discoverer->get();
+ }
+}
diff --git a/src/Support/Caching/DataStructureCache.php b/src/Support/Caching/DataStructureCache.php
new file mode 100644
index 000000000..b94f07495
--- /dev/null
+++ b/src/Support/Caching/DataStructureCache.php
@@ -0,0 +1,72 @@
+store = cache()->store($this->cacheConfig['store'])->getStore();
+ $this->prefix = $this->cacheConfig['prefix'] ? "{$this->cacheConfig['prefix']}." : '';
+ }
+
+ public function getConfig(): ?CachedDataConfig
+ {
+ $serialized = $this->store->get("{$this->prefix}config");
+
+ if ($serialized === null) {
+ return null;
+ }
+
+ try {
+ /** @var CachedDataConfig $cachedConfig */
+ $cachedConfig = unserialize($serialized);
+
+ $cachedConfig->setCache($this);
+
+ return $cachedConfig;
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ public function storeConfig(CachedDataConfig $config): void
+ {
+ $this->store->forever(
+ "{$this->prefix}config",
+ serialize($config),
+ );
+ }
+
+ public function getDataClass(string $className): ?DataClass
+ {
+ $serialized = $this->store->get("{$this->prefix}data-class.{$className}");
+
+ if ($serialized === null) {
+ return null;
+ }
+
+ try {
+ return unserialize($serialized);
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ public function storeDataClass(DataClass $dataClass): void
+ {
+ $this->store->forever(
+ "{$this->prefix}data-class.{$dataClass->name}",
+ serialize($dataClass),
+ );
+ }
+}
diff --git a/src/Support/DataClass.php b/src/Support/DataClass.php
index 809ff872b..a2f699735 100644
--- a/src/Support/DataClass.php
+++ b/src/Support/DataClass.php
@@ -17,7 +17,6 @@
use Spatie\LaravelData\Contracts\WrappableData;
use Spatie\LaravelData\Mappers\ProvidedNameMapper;
use Spatie\LaravelData\Resolvers\NameMappersResolver;
-use Spatie\LaravelData\Support\Lazy\CachedLazy;
use Spatie\LaravelData\Support\NameMapping\DataClassNameMapping;
/**
@@ -25,7 +24,6 @@
* @property Collection $properties
* @property Collection $methods
* @property Collection $attributes
- * @property CachedLazy $outputNameMapping
*/
class DataClass
{
@@ -43,7 +41,7 @@ public function __construct(
public readonly bool $validateable,
public readonly bool $wrappable,
public readonly Collection $attributes,
- public readonly CachedLazy $outputNameMapping,
+ public readonly DataClassNameMapping $outputNameMapping,
) {
}
@@ -75,7 +73,7 @@ public static function create(ReflectionClass $class): self
validateable: $class->implementsInterface(ValidateableData::class),
wrappable: $class->implementsInterface(WrappableData::class),
attributes: $attributes,
- outputNameMapping: new CachedLazy(fn () => self::resolveOutputNameMapping($properties)),
+ outputNameMapping: self::resolveOutputNameMapping($properties),
);
}
diff --git a/src/Support/DataClassMorphMap.php b/src/Support/DataClassMorphMap.php
index d1e604a7e..660aa02da 100644
--- a/src/Support/DataClassMorphMap.php
+++ b/src/Support/DataClassMorphMap.php
@@ -30,8 +30,15 @@ public function add(
/**
* @param array> $map
*/
- public function merge(array $map): self
+ public function merge(array|DataClassMorphMap $map): self
{
+ if ($map instanceof DataClassMorphMap) {
+ $map->map = array_merge($this->map, $map->map);
+ $map->reversedMap = array_merge($this->reversedMap, $map->reversedMap);
+
+ return $this;
+ }
+
foreach ($map as $alias => $class) {
$this->add($alias, $class);
}
diff --git a/src/Support/Lazy/CachedLazy.php b/src/Support/Lazy/CachedLazy.php
deleted file mode 100644
index 525b901b5..000000000
--- a/src/Support/Lazy/CachedLazy.php
+++ /dev/null
@@ -1,36 +0,0 @@
-resolved)) {
- return $this->resolved;
- }
-
- return $this->resolved = ($this->value)();
- }
-}
diff --git a/src/Support/NameMapping/DataClassNameMapping.php b/src/Support/NameMapping/DataClassNameMapping.php
index 35709a3c6..152a4fae9 100644
--- a/src/Support/NameMapping/DataClassNameMapping.php
+++ b/src/Support/NameMapping/DataClassNameMapping.php
@@ -31,8 +31,6 @@ public function resolveNextMapping(
return null;
}
- $outputNameMapping = $dataConfig->getDataClass($dataClass)->outputNameMapping;
-
- return $outputNameMapping->resolve();
+ return $dataConfig->getDataClass($dataClass)->outputNameMapping;
}
}
diff --git a/tests/Commands/DataStructuresCacheCommandTest.php b/tests/Commands/DataStructuresCacheCommandTest.php
new file mode 100644
index 000000000..3e538f0ca
--- /dev/null
+++ b/tests/Commands/DataStructuresCacheCommandTest.php
@@ -0,0 +1,29 @@
+set('data.structure_caching.directories', [
+ __DIR__.'/../Fakes',
+ ]);
+
+ config()->set('data.structure_caching.reflection_discovery.base_path', __DIR__.'/../Fakes');
+ config()->set('data.structure_caching.reflection_discovery.root_namespace', 'Spatie\LaravelData\Tests\Fakes');
+
+ $this->artisan('data:cache-structures')->assertExitCode(0);
+
+ expect(cache()->has('laravel-data.config'))->toBeTrue();
+ expect(cache()->has('laravel-data.data-class.'. SimpleData::class))->toBeTrue();
+
+ App::forgetInstance(DataConfig::class);
+
+ $config = app(DataConfig::class);
+
+ expect($config)->toBeInstanceOf(CachedDataConfig::class);
+ expect($config->getRuleInferrers())->toHaveCount(count(config('data.rule_inferrers')));
+ expect(invade($config)->transformers)->toHaveCount(count(config('data.transformers')));
+ expect(invade($config)->casts)->toHaveCount(count(config('data.casts')));
+});
diff --git a/tests/Support/Caching/CachedDataConfigTest.php b/tests/Support/Caching/CachedDataConfigTest.php
new file mode 100644
index 000000000..6f0fc6212
--- /dev/null
+++ b/tests/Support/Caching/CachedDataConfigTest.php
@@ -0,0 +1,66 @@
+storeConfig($cachedDataConfig);
+
+ $config = app(DataConfig::class);
+
+ expect($config)->toBeInstanceOf(CachedDataConfig::class);
+});
+
+it('will use a non cached config when a cached version is not available', function () {
+ $config = app(DataConfig::class);
+
+ expect($config)->toBeInstanceOf(DataConfig::class);
+});
+
+it('will use a cached data config if the cached version is invalid', function () {
+ ['store' => $store, 'prefix' => $prefix] = config('data.structure_caching.cache');
+
+ cache()->store($store)->forever("{$prefix}.config", serialize(new CachedDataConfig()));
+
+ expect(app(DataConfig::class))->toBeInstanceOf(CachedDataConfig::class);
+
+ cache()->store($store)->forever("{$prefix}.config", 'invalid');
+
+ App::forgetInstance(DataConfig::class);
+
+ expect(app(DataConfig::class))->toBeInstanceOf(DataConfig::class);
+});
+
+it('will load cached data classes', function () {
+ $dataClass = DataClass::create(new ReflectionClass(SimpleData::class));
+
+ $mock = Mockery::mock(
+ new DataStructureCache(config('data.structure_caching.cache')),
+ function (MockInterface $spy) use ($dataClass) {
+ $spy
+ ->shouldReceive('getDataClass')
+ ->with(SimpleData::class)
+ ->andReturn($dataClass)
+ ->once();
+ }
+ )->makePartial()->shouldAllowMockingProtectedMethods();
+
+ $cachedDataConfig = (new CachedDataConfig())->setCache($mock);
+
+ $mock->storeDataClass($dataClass);
+
+ $cachedDataClass = $cachedDataConfig->getDataClass(SimpleData::class);
+
+ expect($cachedDataClass)
+ ->toBeInstanceOf(DataClass::class)
+ ->name->toBe(SimpleData::class);
+});
diff --git a/tests/Support/DataClassTest.php b/tests/Support/DataClassTest.php
index 94b75d1af..3587c7c6f 100644
--- a/tests/Support/DataClassTest.php
+++ b/tests/Support/DataClassTest.php
@@ -83,8 +83,7 @@ public function __construct(
it('wont create an output name mapping for non mapped properties', function () {
$mapping = DataClass::create(new ReflectionClass(SimpleData::class))
- ->outputNameMapping
- ->resolve();
+ ->outputNameMapping;
expect($mapping)
->mapped->toBeEmpty()
diff --git a/tests/Support/NameMapping/DataClassNameMappingTest.php b/tests/Support/NameMapping/DataClassNameMappingTest.php
index 40bbc66d5..170243753 100644
--- a/tests/Support/NameMapping/DataClassNameMappingTest.php
+++ b/tests/Support/NameMapping/DataClassNameMappingTest.php
@@ -24,8 +24,7 @@
/** @var \Spatie\LaravelData\Support\NameMapping\DataClassNameMapping $mapping */
$mapping = app(DataConfig::class)->getDataClass($dataClass::class)
- ->outputNameMapping
- ->resolve();
+ ->outputNameMapping;
expect($mapping->getOriginal('non_mapped'))->toBeNull();
expect($mapping->getOriginal('naam'))->toBe('name');
diff --git a/tests/Support/PartialsParserTest.php b/tests/Support/PartialsParserTest.php
index 9a8d60260..4f3ed56db 100644
--- a/tests/Support/PartialsParserTest.php
+++ b/tests/Support/PartialsParserTest.php
@@ -205,7 +205,7 @@ function complexPartialsProvider(): Generator
public SimpleDataWithMappedOutputName $struct;
};
- $mapping = app(DataConfig::class)->getDataClass($fakeClass::class)->outputNameMapping->resolve();
+ $mapping = app(DataConfig::class)->getDataClass($fakeClass::class)->outputNameMapping;
expect(app(PartialsParser::class))
->execute($partials, $mapping)