Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,50 @@ You can disable caching on a query-by-query basis as needed, like so:
->get();
```

#### ⚠️ Laravel 13 `cache.serializable_classes` — Important

> Laravel 13 introduced [`cache.serializable_classes`](https://laravel.com/docs/13.x/upgrade#cache-serializable_classes-configuration) as a security hardening measure, defaulting to `false` to block deserialization of arbitrary PHP objects from the cache. This package stores `Collection`s of `Address` objects in the cache, which would silently break caching under the new default.

To keep caching working out of the box without forcing you to maintain an
allow-list as new providers are installed, **this package scans the installed
Geocoder vendor directories at boot and merges every model class it finds into
your application's `cache.serializable_classes` allow-list**. Whatever
providers you have installed under `vendor/geocoder-php/*` are covered
automatically — there's no curated list to go stale.

**🔐 Security implication:** the package narrowly relaxes Laravel 13's hardening
for the geocoder model classes installed in your `vendor/` directory. Other
PHP objects you store in the cache remain blocked unless you explicitly allow
them. The blast radius is bounded to classes you've already deliberately
installed via composer.

**Opting out.** Set `auto_register_serializable_classes` to `false` in your
`config/geocoder.php`:

```php
'cache' => [
// ...

'auto_register_serializable_classes' => false,
],
```

When opted out, the package will not touch `cache.serializable_classes` at all.
You then have two reasonable paths:

1. **Manage the allow-list yourself.** Add the geocoder model classes to
`config/cache.php`'s `serializable_classes` directly. Caching keeps working
under your explicit control. Pick this if you want to audit exactly which
PHP objects your application allows to deserialize from cache.

2. **Disable caching for geocoder queries.** Call
`app('geocoder')->doNotCache()` on each query, or set `cache.duration` to
`0` in `config/geocoder.php`. Pick this if you don't want to maintain the
allow-list and can absorb the per-request API cost.

Doing neither under Laravel 13 could cause `__PHP_Incomplete_Class` corruption
on cached results.

### Providers
If you are upgrading and have previously published the geocoder config file, you
need to add the `cache-duration` variable, otherwise cache will be disabled
Expand Down
22 changes: 22 additions & 0 deletions config/geocoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@
*/

'duration' => 9999999,

/*
|-----------------------------------------------------------------------
| Auto-Register Serializable Classes (Laravel 13+)
|-----------------------------------------------------------------------
|
| Laravel 13 hardens cache deserialization via `cache.serializable_classes`,
| which defaults to `false` and blocks all object deserialization. With
| this option enabled (the default), the package scans the installed
| Geocoder vendor directories at boot and merges every model class it
| finds into `cache.serializable_classes`, so caching keeps working with
| any provider you have installed.
|
| Set to `false` to opt out entirely — the package will not touch
| `cache.serializable_classes`, and you take responsibility for managing
| the allow-list yourself (or for disabling caching via `doNotCache()`).
|
| Default: true
|
*/

'auto_register_serializable_classes' => true,
],

/*
Expand Down
196 changes: 183 additions & 13 deletions src/Providers/GeocoderService.php
Original file line number Diff line number Diff line change
@@ -1,48 +1,218 @@
<?php namespace Geocoder\Laravel\Providers;
<?php

/**
* This file is part of the Geocoder Laravel package.
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @author Mike Bronner <hello@genealabs.com>
* @license MIT License
* @author Mike Bronner <mike@genealabs.com>
* @license MIT License
*/

declare(strict_types=1);

namespace Geocoder\Laravel\Providers;

use Geocoder\Laravel\Facades\Geocoder;
use Geocoder\Laravel\ProviderAndDumperAggregator;
use Geocoder\Model\Address;
use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;
use PhpToken;
use ReflectionClass;

class GeocoderService extends ServiceProvider
{
// phpcs:ignore SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingAnyTypeHint
protected $defer = false;
protected static array $discoveredSerializableClasses = [];

public function boot()
public function boot(): void
{
$configPath = __DIR__ . "/../../config/geocoder.php";
$this->publishes(
[$configPath => $this->configPath("geocoder.php")],
"config"
);
$this->publishes([$configPath => $this->configPath("geocoder.php")], "config");
$this->mergeConfigFrom($configPath, "geocoder");
$this->registerSerializableClasses();
}

public function provides(): array
{
return ["geocoder", ProviderAndDumperAggregator::class];
}

public function register()
public function register(): void
{
$this->app->alias("Geocoder", Geocoder::class);
$this->app->singleton(ProviderAndDumperAggregator::class, function () {
return (new ProviderAndDumperAggregator)
->registerProvidersFromConfig(collect(config("geocoder.providers")));
});
$this->app->bind('geocoder', ProviderAndDumperAggregator::class);
$this->app->bind("geocoder", ProviderAndDumperAggregator::class);
}

public function provides() : array
protected function registerSerializableClasses(): void
{
return ["geocoder", ProviderAndDumperAggregator::class];
if (! config("geocoder.cache.auto_register_serializable_classes", true)) {
return;
}

if (self::$discoveredSerializableClasses === []) {
self::$discoveredSerializableClasses = $this->discoverSerializableClasses();
}

$existing = config("cache.serializable_classes");
$existing = is_array($existing)
? $existing
: [];

config([
"cache.serializable_classes" => collect($existing)
->concat(self::$discoveredSerializableClasses)
->unique()
->values()
->toArray(),
]);
}

protected function discoverSerializableClasses(): array
{
$vendorRoot = $this->vendorRoot();

if ($vendorRoot === null) {
return [Collection::class];
}

return collect([
"{$vendorRoot}/willdurand/geocoder/Model",
"{$vendorRoot}/geocoder-php/*/Model",
])
->flatMap(function (string $pattern): array {
return glob($pattern)
?: [];
})
->flatMap(function (string $directory): array {
return glob("{$directory}/*.php")
?: [];
})
->flatMap(function (string $file): array {
return $this->classNamesFromVendorFile($file);
})
->prepend(Collection::class)
->unique()
->values()
->toArray();
}

protected function vendorRoot(): ?string
{
$addressFile = (new ReflectionClass(Address::class))->getFileName();

if ($addressFile === false) {
return null;
}

$directory = dirname($addressFile);

while (
$directory !== ""
&& $directory !== "/"
&& basename($directory) !== "vendor"
) {
$parent = dirname($directory);

if ($parent === $directory) {
return null;
}

$directory = $parent;
}

return basename($directory) === "vendor"
? $directory
: null;
}

protected function classNamesFromVendorFile(string $file): array
{
$contents = file_get_contents($file);

if ($contents === false) {
return [];
}

return $this->extractClassesFromTokens($this->tokenize($contents));
}

protected function tokenize(string $contents): array
{
return array_values(array_filter(
PhpToken::tokenize($contents),
fn (PhpToken $token): bool => ! $token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]),
));
}

protected function extractClassesFromTokens(array $tokens): array
{
$namespace = "";
$classes = [];

foreach ($tokens as $tokenIndex => $token) {
if ($token->is(T_NAMESPACE)) {
$namespace = $this->readNamespaceAt($tokens, $tokenIndex + 1);

continue;
}

if (! $this->isClassDeclaration($tokens, $tokenIndex)) {
continue;
}

$classes[] = $this->qualify($namespace, $tokens[$tokenIndex + 1]->text);
}

return $classes;
}

protected function isClassDeclaration(array $tokens, int $tokenIndex): bool
{
if (
! $tokens[$tokenIndex]->is(T_CLASS)
|| (
$tokenIndex > 0
&& $tokens[$tokenIndex - 1]->is(T_NEW)
)
) {
return false;
}

return isset($tokens[$tokenIndex + 1])
&& $tokens[$tokenIndex + 1]->is(T_STRING);
}

protected function qualify(string $namespace, string $name): string
{
return $namespace !== ""
? "{$namespace}\\{$name}"
: $name;
}

protected function readNamespaceAt(array $tokens, int $startingTokenIndex): string
{
$namespaceParts = [];
$tokenCount = count($tokens);

for ($tokenIndex = $startingTokenIndex; $tokenIndex < $tokenCount; $tokenIndex++) {
if (! $tokens[$tokenIndex]->is([T_STRING, T_NAME_QUALIFIED, T_NS_SEPARATOR])) {
break;
}

$namespaceParts[] = $tokens[$tokenIndex]->text;
}

return implode("", $namespaceParts);
}

protected function configPath(string $path = "") : string
protected function configPath(string $path = ""): string
{
if (function_exists("config_path")) {
return config_path($path);
Expand Down
Loading
Loading