DEV Community

Cover image for Laravel Config Problem: Is It Time for a Revolution?
lotyp
lotyp

Posted on • Edited on

Laravel Config Problem: Is It Time for a Revolution?

๐Ÿ“„ Introduction

While working on the Bridge package Laravel Symfony Serializer, I ran into an unexpected limitation: Laravel configurations don't work with objects.

This issue made me look closely at how Laravel is built and rethink how we set up the framework.

โ†’ In This Article

  • Why caching configs matters
  • The challenge of using objects in configs
  • Existing workarounds and their drawbacks
  • How others tackle this issue
  • Applying the "Strategy" pattern to our problem
  • Spiral Framework's approach and its advantages
  • Potential improvements for Laravel configuration
  • Impact on Developer Experience (DX)

We'll start by identifying the problem, then examine existing solutions, and finally propose a new approach.

This journey won't just solve a specific issue โ€“ it'll give us fresh insights into framework development and evolution.

As developers, we're always pushing our boundaries. That's why we're not just sticking to Laravel โ€“ we're exploring solutions from other frameworks to broaden our perspective.

Let's dive in!

๐Ÿค” Why Caching Configs Matters?

Before we dive into the object-in-config issue, let's tackle a key question: why does Laravel bother caching configs in the first place?

โ†’ The Config Challenge

Every time someone visits a Laravel site, here's what happens behind the scenes:

  1. Laravel reads all the config files
  2. It processes their contents
  3. Then it merges everything into one big array

Sounds simple, right? But there's a catch.

โ†’ The Performance Hit

Let's break it down with a real example. Imagine your app has 20 config files. For each request, Laravel has to:

  1. Open 20 files
  2. Read 20 files
  3. Close 20 files
  4. Process and merge all that data

That's a lot of work, especially when your site gets busy. Each config file needs its own Input/Output (I/O) operation, and in traditional PHP, every new HTTP request kicks off this whole process again.

โ†’ Caching to the Rescue

Here's how Laravel's config caching solves this:

  1. It combines all configs into one array
  2. Saves this array as a single PHP file
  3. On future requests, it reads just this one file

โ†’ The Payoff

  • Speed boost: Significantly cuts down load times
  • Fewer I/O operations: Less strain on your file system
  • Memory efficiency: Configs load once and stay loaded
  • Better scalability: Your app can handle more requests

For live websites, these improvements make a big difference in performance and how well your app can scale.

โ†’ The Object Dilemma

Now, using objects in configs can be great. They offer perks like type safety and auto-completion in your code editor. But here's the rub: they don't play nice with Laravel's caching system.

This clash between speeding things up and making configs more powerful is exactly what we're going to tackle in this article.

๐Ÿคฏ The Object Caching Issue in Laravel Configs

The php artisan config:cache command fails to cache objects in configuration files.

For example, let's place several objects in the config/serializer.php config:

<?php

use Symfony\Component\Serializer\Encoder;

return [
    // ...
    'encoders' => [
        new Encoder\JsonEncoder(),
        new Encoder\CsvEncoder(),
        new Encoder\XmlEncoder(),
    ],
    // ...
];
Enter fullscreen mode Exit fullscreen mode

Attempting to cache this configuration results in an error:

/app $ php artisan config:cache

   LogicException

  Your configuration files are not serializable.

  at vendor/laravel/framework/src/Illuminate/Foundation/Console/ConfigCacheCommand.php:73
     69โ–•             require $configPath;
     70โ–•         } catch (Throwable $e) {
     71โ–•             $this->files->delete($configPath);
     72โ–•
  โžœ  73โ–•             throw new LogicException('Your configuration files are not serializable.', 0, $e);
     74โ–•         }
     75โ–•
     76โ–•         $this->components->info('Configuration cached successfully.');
     77โ–•     }

  1   bootstrap/cache/config.php:807
      Error::("Call to undefined method Symfony\Component\Serializer\Encoder\JsonEncoder::__set_state()")
      +13 vendor frames

  15  artisan:35
      Illuminate\Foundation\Console\Kernel::handle(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
Enter fullscreen mode Exit fullscreen mode

Unexpected, right? Let's unpack what's happening.

โ†’ How the Config Module Works in Laravel

All configuration files are located in the /config folder in the root directory of the project.

These files contain settings for various aspects of the framework, such as the database, caching, sessions, and other components.

When the application is initialized, Laravel loads all configuration files from this directory and combines them into a single configuration array.

This array is made available through the global variable $app['config'] in the application container.

Developers can access configurations from anywhere in the application in three ways:

// Using `Facade`
$timezone = Config::get('app.timezone');

// Using helper function
$timezone = config('app.timezone');

// Directly over Container
$timezone = $app['config']->get('app.timezone');
Enter fullscreen mode Exit fullscreen mode

For package developers, configurations can be published to the application's /config directory:

$ php artisan vendor:publish \
  --provider="WayOfDev\Serializer\Bridge\Laravel\Providers\SerializerServiceProvider" \
  --tag="config"
Enter fullscreen mode Exit fullscreen mode

This allows users of package to easily customize its behavior in their applications.

This system provides flexibility in managing application settings, but, as we will see later, it can create problems when trying to cache configurations with objects.

โ†’ Configuration Caching Process

By default, Laravel reads all configuration files from the /config directory on every request.

To optimize this process and improve performance, the framework provides a configuration caching mechanism.

To create a cached version of all configurations, use the command:

php artisan config:cache
Enter fullscreen mode Exit fullscreen mode

This command does the following:

  1. Reads all files from the /config directory
  2. Combines their contents into one large array
  3. Saves this array as a PHP file in /bootstrap/cache/config.php

The resulting cache file looks something like this:

<?php

return array(
    0 => 'hashing',
    9 => 'broadcasting',
    10 => 'view',
    'app' => array(
        'name' => 'laravel',
        'env' => 'local',
        'debug' => true,
    // ...
        'maintenance' => array(
            'driver' => 'file',
        ),
        'providers' => array(
            0 => 'Illuminate\\Auth\\AuthServiceProvider',
            1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
            // ...
        ),
    ),
);
Enter fullscreen mode Exit fullscreen mode

Once this cached file is created, Laravel will use it instead of reading separate configuration files on every request.

This results in significant performance improvements due to:

  1. Significant reduction in the number of I/O operations: instead of reading many files, only one is read.
  2. Reduced processing time: There is no need to parse and merge separate configuration files.
  3. Reducing the load on the file system: especially noticeable with high traffic.

It's important to note that the benefits of configuration caching are most noticeable in traditional PHP applications, where each request starts a new PHP process.

Long-running applications (such as those using RoadRunner) may not get such a significant performance boost from configuration caching, since they already keep the configuration in memory between requests.

However, even for long-running applications, configuration caching can be useful during initial boot or process restart, allowing for faster initialization.

๐Ÿ’ก Interesting fact:

The cached config should not be stored in the repository, since it contains values from the .env file.

After caching, the env() function becomes useless.

โ†’ Technical Aspect of the Problem

Now that we understand how configuration caching works in Laravel, let's look at why the problem occurs when trying to use objects instead of the usual arrays.

Root of the Problem: var_export()

Laravel serializes configurations using the PHP var_export() function.

In the context of Laravel, the absence of a __set_state() method on objects used in configurations (in my case, Symfony\Component\Serializer\Encoder\JsonEncoder) results in an error when attempting to cache.

Here is the key code snippet from src/Illuminate/Foundation/Console/ConfigCacheCommand.php:

<?php

// ...

$configPath = $this->laravel->getCachedConfigPath();

$this->files->put(
    $configPath, '<?php return '.var_export($config, true).';'.PHP_EOL
);

// ...
Enter fullscreen mode Exit fullscreen mode

The var_export() function works great with arrays and primitive data types, which are used in Laravel configurations by default.

However, there are difficulties in processing objects.

When the var_export() function encounters an object, it attempts to call the static method __set_state() on that object's class.

<?php

class Config {
    public $key = 'value';
}

$object = new Config();

// Tries to call Config::__set_state()
// and throws an error if the method is not defined
echo var_export($object, true);
Enter fullscreen mode Exit fullscreen mode

If the __set_state() method is not implemented in the object class (which is often the case), an error occurs.

Error Breakdown

For example, when trying to cache the configuration of my Laravel Symfony Serializer package, which uses Symfony objects for serialization, the following error occurred:

Error::("Call to undefined method Symfony\Component\Serializer\Encoder\JsonEncoder::__set_state()")
Enter fullscreen mode Exit fullscreen mode

This error can be confusing because the Your configuration files are not serializable message does not directly indicate a problem with the objects.

It is important to note that this problem often does not appear at the development stage, but during deployment to production or staging environments, where the php artisan config:cache command is typically used to optimize performance.

Why is This Important to Solve

While arrays work well for basic configurations, using objects in configurations could provide a number of benefits:

  • Type safety
  • Improved IDE support (autocompletion, tooltips)
  • More structured and object-oriented approach to configuration

Solving these complexities would allow developers to create more flexible and expressive configurations while still maintaining the benefits of Laravel caching.

In the following sections, we'll look at different approaches to solving this problem, which will allow objects to be used in configurations without losing caching capabilities.

๐Ÿ’ญ Hint from Elliot Derhay

While googling for solutions, I found an article by Elliot Derhay where he encountered a similar problem in the package spatie/laravel-markdown.

He proposed a solution by adding Trait Resumeable to classes used as objects in configurations:

<?php

trait Resumeable
{
    public static function __set_state(array $state_array): static
    {
        $object = new static();

        foreach ($state_array as $prop => $state) {
            if (! property_exists($object, $prop)) continue;
            $object->{$prop} = $state;
        }

        return $object;
    }
}
Enter fullscreen mode Exit fullscreen mode

โ†’ Why is this not a Solution?

While this solution may work in some cases, it has a number of problems in the context of my Symfony Bridge package:

  1. Increasing support complexity: Adding Trait to each class would require changing a lot of code, making future support difficult.
  2. Violation of clean code: Classes will contain logic that is not related to their main purpose, which is contrary to the principles of clean code.
  3. Compatibility Issues: Making changes to a third-party library such as Symfony may cause problems with updates to that library.

The main reason is that we cannot modify other people's packages, and this solution is local, not scalable.

โ†’ Working Around the Problem Instead of Solving It

What surprised me was that Spatie, having a lot of influence in the Laravel Community, decided to simply work around the problem instead of solving it in the Laravel core itself.

Their solution was to use only FQCN (Fully Qualified Class Names) instead of objects in configurations:

<?php

return [
    // ...

    'block_renderers' => [
-       // ['renderer' => new MyCustomCodeRenderer(), 'priority' => 0]
+       // ['renderer' => MyCustomCodeRenderer::class, 'priority' => 0]
    ],

    // ...
];
Enter fullscreen mode Exit fullscreen mode

This approach is not flexible and does not provide for supplying optional parameters to the constructor if such are needed. It only works around the problem, not solves it.

โ†’ Reflections on the Spatie Approach

Given Spatie's many contributions to the Laravel community, their decision leaves room for constructive discussion and perhaps re-evaluation of approaches to solving similar problems in the future.

It would be interesting to hear from Spatie and other leading community members on this issue.

๐Ÿ‘€ Hexium got Around This Problem in Their Own Way

When I was working on upgrading my package Laravel Symfony Serializer from Laravel 10.x to laravel 11.x, I decided to see what new things others had come up with.

My search led me to another package Hexium Agency's Symfony Serializer for Laravel which also adds Symfony Serializer support to Laravel.

Let's take a look at how they approached the problem with objects in configurations.

โ†’ Analysis of the Hexium Approach

In config file config/symfony-serializer.php of the package we see that they use string aliases instead of objects:

<?php

return [
    'normalizers' => [
        // ...
        [
            'id' => 'serializer.normalizer.datetimezone',
            'priority' => -915,
        ],
        [
            'id' => 'serializer.normalizer.dateinterval',
            'priority' => -915,
        ],
        [
            'id' => 'serializer.normalizer.datetime',
            'priority' => -910,
        ],
        [
            'id' => 'serializer.normalizer.json_serializable',
            'priority' => -950,
        ],
        // ...
    ],
    'encoders' => [
        // ...
        [
            'id' => 'serializer.encoder.xml',
        ],
        [
            'id' => 'serializer.encoder.json',
        ],
        // ...
    ],

    // ...
];
Enter fullscreen mode Exit fullscreen mode

When looking at the Service Provider, it becomes clear that they strictly prescribe the creation of these services:

SymfonySerializerForLaravelServiceProvider.php

<?php

class SymfonySerializerForLaravelServiceProvider extends PackageServiceProvider
{
    // ...

    public function registeringPackage(): void
    {
        // ...

        // Encoders
        $this->app->bind('serializer.encoder.xml', static function () {
            return new XmlEncoder();
        });
        $this->app->tag('serializer.encoder.xml', ['serializer.encoder']);

        $this->app->bind('serializer.encoder.json', static function () {
            return new JsonEncoder(null, null);
        });
        $this->app->tag('serializer.encoder.json', ['serializer.encoder']);

        $this->app->bind('serializer.encoder.yaml', static function () {
            return new YamlEncoder();
        });
        $this->app->tag('serializer.encoder.yaml', ['serializer.encoder']);

        $this->app->bind('serializer.encoder.csv', static function () {
            return new CsvEncoder();
        });
        $this->app->tag('serializer.encoder.csv', ['serializer.encoder']);

        // ...
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

โ†’ Workaround Instead of Solution

Having considered the approach of this package, we can highlight the following features:

Pros:

  • Ability to override settings values using those aliases that are already defined in the package

Cons:

  • To add new objects to the config, you need to create a binding in the Service Provider
  • The approach violates the principles of configuration flexibility and expandability
  • The configuration becomes rigid, requiring changes to the Service Provider to add new settings
  • Using aliases instead of objects deprives configuration of benefits such as IDE autocompletion and type safety
  • Package customization involves making changes directly to the code of the Hexium package itself

This approach was most likely caused by the inability to use objects directly in Laravel configuration files.

The authors of the package decided to work around this problem, but at the same time sacrificed flexibility, convenience and extensibility of the configuration.

Thus, this solution cannot be called a complete one, but rather a workaround that has significant limitations.

๐Ÿ˜ฌ My intermediate attempt to solve the problem

After analyzing existing solutions such as the Spatie and Hexium approaches, which were essentially workarounds for the problem, I decided to implement my own approach based on the Strategy pattern.

โ†’ Solution Strategy in The Strategy Pattern!

I created interfaces for Registration Strategies encoders and normalizers.

This allowed for flexibility and expandability of the configuration without violating the SOLID principles.

For example, here is the interface for the Encoders Registration Strategy:

<?php

declare(strict_types=1);

namespace WayOfDev\Serializer\Contracts;

use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Encoder\EncoderInterface;

interface EncoderRegistrationStrategy
{
    /**
     * @return iterable<array{encoder: EncoderInterface|DecoderInterface}>
     */
    public function encoders(): iterable;
}
Enter fullscreen mode Exit fullscreen mode

And its implementation to register default encoders:

<?php

declare(strict_types=1);

namespace WayOfDev\Serializer;

use Symfony\Component\Serializer\Encoder;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Encoder\EncoderInterface;

final class DefaultEncoderRegistrationStrategy implements Contracts\EncoderRegistrationStrategy
{
    /**
     * @return iterable<array{encoder: EncoderInterface|DecoderInterface}>
     */
    public function encoders(): iterable
    {
        yield ['encoder' => new Encoder\JsonEncoder()];
        yield ['encoder' => new Encoder\CsvEncoder()];
        yield ['encoder' => new Encoder\XmlEncoder()];
    }
}
Enter fullscreen mode Exit fullscreen mode

The config file for my laravel-symfony-serializer package config/serializer.php now looked like this:

<?php

use WayOfDev\Serializer\DefaultEncoderRegistrationStrategy;
use WayOfDev\Serializer\DefaultNormalizerRegistrationStrategy;

return [
    // ...

    /*
     * Allows you to specify the strategy class for registering your normalizers.
     * Default is 'WayOfDev\Serializer\DefaultNormalizerRegistrationStrategy'.
     */
    'normalizerRegistrationStrategy' => DefaultNormalizerRegistrationStrategy::class,

    /*
     * Allows you to register your custom encoders.
     * Default encoders are registered in src/DefaultEncoderRegistrationStrategy.php.
     *
     * Default encoders include:
     *      JsonEncoder,
     *      CsvEncoder,
     *      XmlEncoder,
     *      YamlEncoder.
     *
     * You can replace the default encoders with your custom ones by implementing
     * your own registration strategy and defining it here.
     */
    'encoderRegistrationStrategy' => DefaultEncoderRegistrationStrategy::class,

    // ...
];
Enter fullscreen mode Exit fullscreen mode

Registration happens here: SerializerServiceProvider.php:

<?php

// ...

use WayOfDev\Serializer\Contracts\EncoderRegistrationStrategy;
use WayOfDev\Serializer\Contracts\EncoderRegistryInterface;
use WayOfDev\Serializer\Contracts\ConfigRepository;

final class SerializerServiceProvider extends ServiceProvider
{
    // ...

    private function registerEncoderRegistry(): void
    {
        $this->app->singleton(EncoderRegistrationStrategy::class, static function (Application $app): EncoderRegistrationStrategy {
            /** @var Config $config */
            $config = $app->make(ConfigRepository::class);

            $strategyFQCN = $config->encoderRegistrationStrategy();

            return $app->make($strategyFQCN);
        });

        $this->app->singleton(EncoderRegistryInterface::class, static function (Application $app): EncoderRegistryInterface {
            /** @var EncoderRegistrationStrategy $strategy */
            $strategy = $app->get(EncoderRegistrationStrategy::class);

            return new EncoderRegistry($strategy);
        });
    }

    // ...
}

Enter fullscreen mode Exit fullscreen mode

โ†’ What's The Difference?

This approach has a number of advantages compared to the previously discussed options:

  • Flexibility: Users can easily replace the standard strategy with their own without changing the core code of the package.
  • Extensibility: Adding new encoders or normalizers does not require changing the core code of the laravel-symfony-serializer package.
  • Encapsulation: The logic for creating and configuring encoders and normalizers is encapsulated in separate classes, which improves code organization.
  • Adherence to SOLID principles: This approach better adheres to the open/closed principle, allowing functionality to be extended without changing existing code.

However, this approach also has some disadvantages:

  • Challenge for the user: To make changes, the user needs to create their registration strategy as a separate class and store it in their project.
  • More Code: This approach requires writing more code than simply defining the array in a config file.
  • Potential DX Complication: From a Developer Experience (DX) perspective, this approach may seem more complex to new users of the package.

Although this intermediate approach is not a perfect solution, it provides a more flexible and extensible solution than previous options and better aligns with object-oriented programming principles. However, as we will see later, there is a more complete solution.

๐Ÿค” What's Wrong With All These Approaches?

After looking at the various work-arounds around the object problem in Laravel configurations, it becomes clear that each approach has its limitations and does not solve core problem. Let's analyze them in more detail:

โ†’ Using FQCN (Fully Qualified Class Names)

The approach proposed by Spatie and Elliot Derhay in package spatie/laravel-markdown, although it solves the problem of configuration serialization, significantly limits configuration flexibility:

'block_renderers' => [
    ['renderer' => MyCustomCodeRenderer::class, 'priority' => 0]
],
Enter fullscreen mode Exit fullscreen mode

This approach does not allow parameters to be passed to the class constructor, which can be critical for complex objects with custom behavior.

Developers have to find workarounds to initialize objects with the desired parameters, which complicates the code and reduces its readability.

โ†’ Hardcoding of Dependencies in The Service Provider

The Hexium approach, where dependencies are hardcoded in the Service Provider, violates the SOLID (Open/Closed) principle:

$this->app->bind('serializer.encoder.json', static function () {
    return new JsonEncoder(null, null);
});
Enter fullscreen mode Exit fullscreen mode

This approach makes it difficult to extend and modify behavior without changing the package source code.

If a user needs to change the JsonEncoder configuration, they will have to redefine the entire Service Provider, which can lead to code duplication and become more difficult to maintain with package updates.

โ†’ Lack of Dependency Injection Support

All considered approaches do not take into account the possibility of using Dependency Injection in object constructors.

For example, if we have a class with dependencies:

class MyCustomCodeRenderer {
   public function __construct(
       public LoggerInterface $logger,
       public int $priority = 100,
   ){}
}
Enter fullscreen mode Exit fullscreen mode

None of the approaches discussed make it easy to pass LoggerInterface when creating an object via configuration. This forces developers to find workarounds or complicate the application architecture.

โ†’ Lack of a Unified Approach in The Laravel Ecosystem

The lack of a standard solution for working with objects in Laravel configurations results in different packages taking different approaches.

This makes it difficult to understand and integrate different packages in one project.

โšก๏ธ How It Is Implemented in The Spiral Framework

โ†’ About The Spiral Framework

Spiral Framework is a modern PHP framework for developing enterprise applications that supports: high-performance request processing using RoadRunner, an efficient queuing system, Temporal workflows, WebSockets, gRPC and microservice architecture.

It is designed with an emphasis on intuitiveness and ease of use, offering a Developer Experience similar to Laravel and Symfony.

โ†’ Container Auto-Wiring

Spiral attempts to hide container implementation and configuration from your application's domain layer by providing rich auto-wiring functionality that allows you to delegate object creation to the container.

This makes managing dependencies in your application much easier.

When the container attempts to resolve Autowire, it automatically instantiates the class specified in the first argument and passes additional parameters if specified in the second argument.

The key element of this approach is the Spiral\Core\Container\Autowire class.

Let's look at the implementation of the class in more detail:

<?php

namespace Spiral\Core\Container;

// ...

final class Autowire
{
    private ?object $target = null;

    public function __construct(
        private readonly string $alias,
        private readonly array $parameters = []
    ) {
    }

    public static function __set_state(array $anArray): static
    {
        return new self($anArray['alias'], $anArray['parameters']);
    }

    // ...

    public function resolve(FactoryInterface $factory, array $parameters = []): object
    {
        return $this->target ?? $factory->make($this->alias, \array_merge($this->parameters, $parameters));
    }
}

Enter fullscreen mode Exit fullscreen mode

This class allows:

  1. Specify the class or alias of the service that needs to be created ($alias), for Laravel developers this will be equivalent to the $abstract parameter in the app()โ†’make() method
  2. Pass parameters to the constructor of this class ($parameters).
  3. Postpone the creation of the object until the moment when it is really needed (resolve method).

Pay attention to the __set_state method. It solves the problem we had previously when using var_export() to cache configurations.

โ†’ Using Autowire in Configurations

Now let's look at how this can help us solve the problem with objects in Laravel configurations.

Let's remember the example of a class from Spatie, where the constructor had an external dependency:

<?php

class MyCustomCodeRenderer {
   public function __construct(
       public LoggerInterface $logger,
       public int $priority = 100,
   ){}
}
Enter fullscreen mode Exit fullscreen mode

Using the Spiral approach with Autowire, we could configure this class in our config as follows:

return [
    // ...
    'block_renderers' => [
       new Autowire(MyCustomCodeRenderer::class, ['priority' => 50]),
    ],
    // ...
];
Enter fullscreen mode Exit fullscreen mode

This approach has several advantages:

  1. We can use objects in configurations without worrying about serialization issues.
  2. External dependencies (for example, LoggerInterface) will be automatically resolved by the container.
  3. We can override only those parameters that we need (in this case, priority).
  4. The creation of an object is postponed until the moment when it is actually needed.

This approach allows us to achieve a balance between configuration flexibility and performance, solving the problems we encountered previously.

In the next section, we'll look at how we could adapt this approach for use in Laravel.

๐Ÿ’ก How can this be solved in Laravel Framework

After analyzing existing approaches and studying the solution described above, we can propose a more elegant solution for Laravel that will allow objects to be used in configurations while maintaining caching capabilities.

โ†’ Making the AutoWire as a Wrapper Class

Inspired by the Spiral Framework solution, we can create an AutoWire class that will serve as a wrapper for objects in configurations. This class will implement the magic method __set_state(), allowing it to be used with var_export().

Here is the concept for implementing the AutoWire class:

<?php

declare(strict_types=1);

namespace Support;

use Illuminate\Contracts\Container\BindingResolutionException;

final readonly class AutoWire
{
    /**
     * Create a new AutoWire instance.
     *
     * @param array<string, mixed> $parameters
     */
    public function __construct(private string $abstract, private array $parameters = [])
    {
    }

    /**
     * Magic method for var_export().
     *
     * @param array{abstract: string, parameters: array<string, mixed>} $properties
     *
     * @return static
     */
    public static function __set_state(array $properties): self
    {
        return new self($properties['abstract'], $properties['parameters']);
    }

    /**
     * Resolve the AutoWire instance using the container.
     *
     * @throws BindingResolutionException
     */
    public function resolve(): mixed
    {
        return app()->make($this->abstract, $this->parameters);
    }
}
Enter fullscreen mode Exit fullscreen mode

This class performs the following functions:

  1. Stores the name of the class ($abstract) and the parameters for creating it ($parameters).
  2. Implements the __set_state() method, which allows you to recreate the object after serialization.
  3. Provides the resolve() method, which uses the Laravel container to lazily load an object.

โ†’ Using AutoWire in Our Config Files

Now we can change our configuration file config/serializer.php using AutoWire:

<?php

use Support\AutoWire;
use Symfony\Component\Serializer\Encoder;
use Symfony\Component\Serializer\Encoder\JsonDecode;

return [
    // ...

    'encoders' => [
        new AutoWire(
            abstract: Encoder\JsonEncoder::class,
            parameters: [
                'defaultContext' => [
                    JsonDecode::ASSOCIATIVE => true,
                ],
            ]
        ),
        new AutoWire(Encoder\CsvEncoder::class),
        Encoder\XmlEncoder::class,
        Encoder\YamlEncoder::class,
    ],

    // ...
];
Enter fullscreen mode Exit fullscreen mode

The Service Provider will now look like this:

<?php

// ...

final class SerializerServiceProvider extends ServiceProvider
{
    // ...

    private function registerEncoderRegistry(): void
    {
        $this->app->singleton(EncoderRegistryInterface::class, static function (Application $app): EncoderRegistryInterface {
            /** @var Config $config */
            $config = $app->make(ConfigRepository::class);

            return new EncoderRegistry(
                collect($config->encoders())->map(static function (string|AutoWire|EncoderInterface $encoder) use ($app) {
                    if ($encoder instanceof EncoderInterface) {
                        return $encoder;
                    }

                    if ($encoder instanceof AutoWire) {
                        return $encoder->resolve();
                    }

                    return $app->make($encoder);
                })->all()
            );
        });
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

This approach allows us to:

  1. Use objects in configurations
  2. Pass parameters to object constructors.
  3. Maintain the ability to cache configurations.
  4. Use standard FQCN strings if there is no need for objects

โ†’ Running

After making the changes, we can try running the configuration caching command:

/app $ php artisan config:cache

   INFO  Configuration cached successfully.
Enter fullscreen mode Exit fullscreen mode

As we can see, the command is executed successfully, without errors.

If we look at the contents of the cached configuration file bootstrap/cache/config.php, we see the following:

<?php

declare(strict_types=1);

return [
    // ...
    'serializer' => [
        // ...
        'encoders' => [
            0 => Support\AutoWire::__set_state([
                'abstract' => 'Symfony\\Component\\Serializer\\Encoder\\JsonEncoder',
                'parameters' => [
                    'defaultContext' => [
                        'json_decode_associative' => true,
                    ],
                ],
            ]),
            1 => Support\AutoWire::__set_state([
                'abstract' => 'Symfony\\Component\\Serializer\\Encoder\\CsvEncoder',
                'parameters' => [
                ],
            ]),
            2 => Support\AutoWire::__set_state([
                'abstract' => 'Symfony\\Component\\Serializer\\Encoder\\XmlEncoder',
                'parameters' => [
                ],
            ]),
        ],
    ],
];
Enter fullscreen mode Exit fullscreen mode

โ†’ How Does It Work?

  1. When caching configurations, Laravel uses var_export() to serialize the configuration array.
  2. For AutoWire objects, the __set_state() method is called, which saves information about the class and its parameters.
  3. When loading a cached configuration, AutoWire objects are restored using __set_state().
  4. When a real object is required, the resolve() method is called, which uses the Laravel container to create an object with the required parameters.

When does resolve() run?

The resolve() method is called when the dependency container attempts to instantiate an object. This happens "lazyly", that is, only when the object is actually needed.

This approach allows us to use objects in configurations while still being cacheable. It also provides flexibility in configuring objects by allowing parameters to be passed to their constructors.

๐Ÿ—๏ธ Going even further, what if we use DTO in the configuration?

Everything we've covered so far could be solved without making any changes to the Laravel core. But what if we go further and think about more radical changes?

โ†’ Simple Example: Authentication Configuration

Have you ever experienced difficulty configuring Laravel? How often do you have to look at the documentation when you need, for example, to configure an authentication driver?

Let's do a thought experiment: look at this piece of the config and try to remember what keys should be in the auth.php config without looking at the documentation:

<?php

return [
    // ...

    'passwords' => [
        'users' => [
            '???' => 'users',
            '???' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
            '???' => 60,
            '???' => 60,
        ],
    ],

    // ...
];
Enter fullscreen mode Exit fullscreen mode

I am sure many of you will not be able to remember even the first of the keys. The problem is that arrays do not support autocompletion. This is only possible when using additional paid plugins for the IDE.

Now let's imagine that instead of an array we use an object:

<?php

declare(strict_types=1);

namespace Support\Auth\Config;

use ReflectionClass;
use ReflectionException;

use function array_key_exists;

final readonly class PasswordConfig
{
    /**
     * @param non-empty-string $provider
     * @param non-empty-string $table
     * @param int<0, max> $expire
     * @param int<0, max> $throttle
     */
    public function __construct(
        public string $provider,
        public string $table,
        public int $expire = 60,
        public int $throttle = 60,
    ) {
    }

    /**
     * @param array<string, mixed> $properties
     *
     * @throws ReflectionException
     */
    public static function __set_state(array $properties): self
    {
        $ref = new ReflectionClass(self::class);

        $arguments = [];
        foreach ($ref->getConstructor()?->getParameters() ?? [] as $parameter) {
            $name = $parameter->getName();
            $arguments[$name] = array_key_exists($name, $properties)
                ? $properties[$name]
                : $parameter->getDefaultValue();
        }

        return new self(...$arguments);
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's change the auth.php config itself in empty Laravel Application and see how it will be now:

<?php

use Support\Auth\Config\PasswordConfig;

return [
    // ...

    'passwords' => [
        'users' => new PasswordConfig(
            provider: 'users',
            table: 'password_reset_tokens',
            expire: 60,
            throttle: 60,
        ),
    ],

    // ...
];
Enter fullscreen mode Exit fullscreen mode

Here we are using PHP 8.0 Named Arguments, but even without using them everything has become much simpler and clearer: we just need to look at the parameters of the PasswordConfig constructor. And if we use PHPStorm or similar IDEs, then the tooltips will be automatically available out of the box.

Also, such a class already contains the __set_state function which will allow us to use the existing Laravel php artisan config:cache mechanism.

โ†’ Complex Example: Database Configuration

But let's dig deeper and look at a more complex example - database configuration.

I use this approach in my wayofdev/laravel-cycle-orm-adapter package. The configuration file can be viewed here: config/cycle.php

Let's take a look at the default database.php configuration file in Laravel.

What it looks like now:

<?php

return [
    // ...

    'connections' => [
        'memory' => [
            'driver' => 'sqlite',
            'url' => env('DB_URL'),
            'database' => ':memory:',
            'prefix' => '',
            'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
        ],

        'sqlite' => [
            'driver' => 'sqlite',
            'url' => env('DB_URL'),
            'database' => env('DB_DATABASE', database_path('database.sqlite')),
            'prefix' => '',
            'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
        ],

        'mysql' => [
            'driver' => 'mysql',
            'url' => env('DB_URL'),
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('DB_DATABASE', 'laravel'),
            'username' => env('DB_USERNAME', 'root'),
            'password' => env('DB_PASSWORD', ''),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => env('DB_CHARSET', 'utf8mb4'),
            'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
            'prefix' => '',
            'prefix_indexes' => true,
            'strict' => true,
            'engine' => null,
            'options' => extension_loaded('pdo_mysql') ? array_filter([
                PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
            ]) : [],
        ],

        // ...    
    ],
];
Enter fullscreen mode Exit fullscreen mode

What this might look like if we use a DTO for configuration:

<?php

use Support\Database\Config;

return [
    // ...

    'connections' => [
        'memory' => new Config\SQLiteDriverConfig(
            connection: new Config\SQLite\MemoryConnectionConfig(),
            driver: 'sqlite',
        ),

          'sqlite' => new Config\SQLiteDriverConfig(
              connection: new Config\SQLite\FileConnectionConfig(
                  url: env('DB_URL'),
                  database: env('DB_DATABASE', database_path('database.sqlite'))
              ),
              driver: 'sqlite',
              prefix: '',
              foreign_key_constraints: env('DB_FOREIGN_KEYS', true),
          ),

        'mysql' => new Config\MySQLDriverConfig(
            connection: new Config\MySQL\TcpConnectionConfig(
                url: env('DB_URL'),
                database: env('DB_DATABASE', 'laravel'),
                host: env('DB_HOST', '127.0.0.1'),
                port: env('DB_PORT', 3306),
                username: env('DB_USERNAME', 'root'),
                password: env('DB_PASSWORD', ''),
                unix_socket: env('DB_SOCKET', ''),
            ),
            driver: 'mysql',
            options: extension_loaded('pdo_mysql') ? array_filter([
                PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
            ]) : [],
            charset: env('DB_CHARSET', 'utf8mb4'),
            collation: env('DB_COLLATION', 'utf8mb4_unicode_ci'),
            prefix: '',
            prefix_indexes: true,
            strict: true,
            engine: null,
        ),

        // ...    
    ],
];
Enter fullscreen mode Exit fullscreen mode

This approach provides all the benefits of using DTOs and PHP 8.0 Named Arguments that we touched on earlier.

โ†’ What Conclusions Can We Make?

Using the AutoWire class together with configuration DTOs provides a number of significant advantages:

  • Improved structure and typing: DTOs provide clear configuration structure, and strong typing helps prevent compile-time errors.
  • Usability: Named Arguments in PHP 8.0 make configuration more readable and less prone to typing errors.
  • IDE Support: Object-oriented approach provides better support for autocompletion and tooltips in modern IDEs.
  • Structure-level validation: DTOs allow you to build basic validation directly into the object structure.
  • Cache Compatibility: The __set_state() method in DTO and AutoWire provides compatibility with Laravel's configuration caching mechanism.
  • Improved Documentation: The DTO structure serves as self-documenting code, making configuration easier to understand.
  • Polymorphism capability: You can use inheritance and interfaces to create more complex configurations.
  • Combination with AutoWire: Using AutoWire allows you to defer object creation and dependency injection while still maintaining the benefits of DTO.

This approach significantly improves the Developer Experience, making configuration work more intuitive and less error-prone.

๐Ÿ˜Œ Conclusion: New Horizons

Our journey through the labyrinths of Laravel has come to an end, but this is only the beginning. We've gone from discovering a problem with caching configurations with objects to creating a potential solution that could change the way we work with Laravel configuration.

Now I encourage you to join this journey:

  1. ๐Ÿค” What do you think of the proposed solution using AutoWire and configuration DTOs? Do you see potential problems or improvements?
  2. ๐Ÿ’ก Do you have experience in solving similar problems in your projects? How have you dealt with the limitations of Laravel configurations?
  3. ๐Ÿ”ฎ Do you think Laravel should evolve in this direction? What other aspects of the framework do you think need improvement?
  4. ๐ŸŒˆ What other ideas do you have for improving the Developer Experience in Laravel or other frameworks?

Let's continue this conversation! Share your thoughts in the comments. Your experience and ideas could be the key to the next big thing in the development world.

๐Ÿš€ Let's continue the journey together!

Top comments (1)

Collapse
 
sergey_poprygin profile image
Sergey Poprygin • Edited

i think you should no use objects in configs because those objects will be instantiated during config read process, an object should be instantiated only when it needs during runtime, i think laravel uses bindings with callable in service provider because it makes an object lazy instantiated, and you suggest to instantiate objects during config read process, even if some of those objects will not be used further during request process or even all requests if i understood you correct