Published Apr 29, 2023

Last Updated Aug 27, 2023

Caching Laravel configs that use objects

Categories: Laravel

Tags: #Caching

Real quick: If you’re using spatie/laravel-markdown and having this issue (how I ran into this), it was partially fixed with PR#53 recently.


This headline might be a bit of a head-scratcher for you. It comes from an issue that I ran into recently—and that I didn’t dig into right away.

Basically, running artisan config:cache started giving me errors about not being able to serialize my configs.

Rewind

If you haven’t used Laravel before, one of its directories in the project root is config/. This directory houses PHP files for project settings—mostly Laravel’s, but you can create and register your own too. Some installed packages also let you publish their configs to that directory for you to customize.

The combined application config is normally rebuilt for every request. To speed this up, Laravel has a config:cache command to generate 1 config file. This includes any values that might’ve been resolved dynamically, like from function calls. And it usually “just works”.

When “just works”, doesn’t

Sometimes you might need an object in your config, like if you’re using Spatie’s Laravel Markdown package and creating custom renderers.

For example:

<?php
return [
  // ...
  'block_renderers' => [
    ['class' => BlockQuote::class, 'renderer' => new BlockQuoteRenderer(), 'priority' => 1],
    ['class' => Heading::class, 'renderer' => new HeadingRenderer(), 'priority' => 1],
    ['class' => ListItem::class, 'renderer' => new ListItemRenderer(), 'priority' => 1],
  ],
  //...
];

Now, this doesn’t look bad. But if you do this as-is and then run config:cache, you'll get an error similar to this:

  LogicException

  Your configuration files are not serializable.

  at /path/to/project/vendor/laravel/framework/src/Illuminate/Foundation/Console/ConfigCacheCommand.php:80
     76require $configPath;
     77▕         } catch (Throwable $e) {
     78$this->files->delete($configPath);
     79
80throw new LogicException('Your configuration files are not serializable.', 0, $e);
     81}
     82
     83$this->info('Configuration cached successfully!');
     84▕     }

  1   /path/to/project/bootstrap/cache/config.php:<line-number>
      Error::("Call to undefined method App\Markdown\Renderers\BlockQuoteRenderer::__set_state()")

  2   /path/to/project/vendor/laravel/framework/src/Illuminate/Foundation/Console/ConfigCacheCommand.php:76
      require()

What causes this?

The error message might need clarifying. For 1, it says “not serializeable”. That might lead you to believe config:cache is calling serialize() under the hood, but it’s not.

It’s also not pointing out which config it’s having trouble with.

But with some digging through laravel/framework, we find this section of code:

<?php
//...
        $configPath = $this->laravel->getCachedConfigPath();

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

        try {
            require $configPath;
        } catch (Throwable $e) {
          // ...
        }
// ...

In there, there’s a call to var_export(). If we look at the PHP manual for that function, it shows examples of different types of output.

But look specifically at Example #3: Exporting Classes. It has this sample output:

A::__set_state(array(
   'var' => 5,
))

You might remember that that static method, __set_state(), appears in the stack trace from earlier.

If we read PHP’s magic methods page, we’ll see that this static method allows parsing an exported object string back into an object while automatically providing it with its old exported data!

And rereading that snippet from laravel/framework, what’s happening makes sense now. When calling config:cache:

  1. Laravel is reading in the full, generated config and generating a combined PHP file from that. Then,
  2. it’s immediately reading in this pre-built PHP file, triggering calls to that magic method.

So it seems this error is just a side effect of how config caching works.

So how do we get around this?

We can implement __set_state() for classes we “new up” in our configs. The return value should be an instance of that class too.

For my renderers, I wrote a trait:

<?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;
	}
}

I tested this on PHP 8.1. Since it’s being called from within the class it affects, it also works on private and protected properties.

Good thing too, because var_export() exports data from private and protected properties.

Summary

You may not need this very often. And if you’re using spatie/laravel-markdown, PR#53 did get merged pretty quickly, so you can just use class strings now (Thanks erikn69 and freekmurze!)

But this is 1 way to get around config caching issues you may run into.

Thanks for reading!

Please confirm whether you would like to allow tracking cookies on this website, in accordance with its privacy policy.