Skip to content

Commit 1e7d73c

Browse files
committed
feat: ✨ Auto-register Geocoder model classes for Laravel 13 cache serialization.
1 parent 68a3831 commit 1e7d73c

7 files changed

Lines changed: 365 additions & 13 deletions

File tree

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,50 @@ You can disable caching on a query-by-query basis as needed, like so:
112112
->get();
113113
```
114114

115+
#### ⚠️ Laravel 13 `cache.serializable_classes` — Important
116+
117+
> 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.
118+
119+
To keep caching working out of the box without forcing you to maintain an
120+
allow-list as new providers are installed, **this package scans the installed
121+
Geocoder vendor directories at boot and merges every model class it finds into
122+
your application's `cache.serializable_classes` allow-list**. Whatever
123+
providers you have installed under `vendor/geocoder-php/*` are covered
124+
automatically — there's no curated list to go stale.
125+
126+
**🔐 Security implication:** the package narrowly relaxes Laravel 13's hardening
127+
for the geocoder model classes installed in your `vendor/` directory. Other
128+
PHP objects you store in the cache remain blocked unless you explicitly allow
129+
them. The blast radius is bounded to classes you've already deliberately
130+
installed via composer.
131+
132+
**Opting out.** Set `auto_register_serializable_classes` to `false` in your
133+
`config/geocoder.php`:
134+
135+
```php
136+
'cache' => [
137+
// ...
138+
139+
'auto_register_serializable_classes' => false,
140+
],
141+
```
142+
143+
When opted out, the package will not touch `cache.serializable_classes` at all.
144+
You then have two reasonable paths:
145+
146+
1. **Manage the allow-list yourself.** Add the geocoder model classes to
147+
`config/cache.php`'s `serializable_classes` directly. Caching keeps working
148+
under your explicit control. Pick this if you want to audit exactly which
149+
PHP objects your application allows to deserialize from cache.
150+
151+
2. **Disable caching for geocoder queries.** Call
152+
`app('geocoder')->doNotCache()` on each query, or set `cache.duration` to
153+
`0` in `config/geocoder.php`. Pick this if you don't want to maintain the
154+
allow-list and can absorb the per-request API cost.
155+
156+
Doing neither under Laravel 13 could cause `__PHP_Incomplete_Class` corruption
157+
on cached results.
158+
115159
### Providers
116160
If you are upgrading and have previously published the geocoder config file, you
117161
need to add the `cache-duration` variable, otherwise cache will be disabled

config/geocoder.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,28 @@
3838
*/
3939

4040
'duration' => 9999999,
41+
42+
/*
43+
|-----------------------------------------------------------------------
44+
| Auto-Register Serializable Classes (Laravel 13+)
45+
|-----------------------------------------------------------------------
46+
|
47+
| Laravel 13 hardens cache deserialization via `cache.serializable_classes`,
48+
| which defaults to `false` and blocks all object deserialization. With
49+
| this option enabled (the default), the package scans the installed
50+
| Geocoder vendor directories at boot and merges every model class it
51+
| finds into `cache.serializable_classes`, so caching keeps working with
52+
| any provider you have installed.
53+
|
54+
| Set to `false` to opt out entirely — the package will not touch
55+
| `cache.serializable_classes`, and you take responsibility for managing
56+
| the allow-list yourself (or for disabling caching via `doNotCache()`).
57+
|
58+
| Default: true
59+
|
60+
*/
61+
62+
'auto_register_serializable_classes' => true,
4163
],
4264

4365
/*

src/Providers/GeocoderService.php

Lines changed: 146 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,181 @@
1-
<?php namespace Geocoder\Laravel\Providers;
1+
<?php
22

33
/**
44
* This file is part of the Geocoder Laravel package.
55
* For the full copyright and license information, please view the LICENSE
66
* file that was distributed with this source code.
77
*
8-
* @author Mike Bronner <hello@genealabs.com>
9-
* @license MIT License
8+
* @author Mike Bronner <mike@genealabs.com>
9+
* @license MIT License
1010
*/
1111

12+
declare(strict_types=1);
13+
14+
namespace Geocoder\Laravel\Providers;
15+
1216
use Geocoder\Laravel\Facades\Geocoder;
1317
use Geocoder\Laravel\ProviderAndDumperAggregator;
18+
use Illuminate\Support\Collection;
1419
use Illuminate\Support\ServiceProvider;
20+
use PhpToken;
1521

1622
class GeocoderService extends ServiceProvider
1723
{
24+
// phpcs:ignore SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingAnyTypeHint
1825
protected $defer = false;
26+
protected static array $discoveredSerializableClasses = [];
1927

20-
public function boot()
28+
public function boot(): void
2129
{
2230
$configPath = __DIR__ . "/../../config/geocoder.php";
23-
$this->publishes(
24-
[$configPath => $this->configPath("geocoder.php")],
25-
"config"
26-
);
31+
$this->publishes([$configPath => $this->configPath("geocoder.php")], "config");
2732
$this->mergeConfigFrom($configPath, "geocoder");
33+
$this->registerSerializableClasses();
34+
}
35+
36+
public function provides(): array
37+
{
38+
return ["geocoder", ProviderAndDumperAggregator::class];
2839
}
2940

30-
public function register()
41+
public function register(): void
3142
{
3243
$this->app->alias("Geocoder", Geocoder::class);
3344
$this->app->singleton(ProviderAndDumperAggregator::class, function () {
3445
return (new ProviderAndDumperAggregator)
3546
->registerProvidersFromConfig(collect(config("geocoder.providers")));
3647
});
37-
$this->app->bind('geocoder', ProviderAndDumperAggregator::class);
48+
$this->app->bind("geocoder", ProviderAndDumperAggregator::class);
3849
}
3950

40-
public function provides() : array
51+
protected function registerSerializableClasses(): void
4152
{
42-
return ["geocoder", ProviderAndDumperAggregator::class];
53+
if (! config("geocoder.cache.auto_register_serializable_classes", true)) {
54+
return;
55+
}
56+
57+
if (self::$discoveredSerializableClasses === []) {
58+
self::$discoveredSerializableClasses = $this->discoverSerializableClasses();
59+
}
60+
61+
$existing = config("cache.serializable_classes");
62+
$existing = is_array($existing)
63+
? $existing
64+
: [];
65+
66+
config([
67+
"cache.serializable_classes" => collect($existing)
68+
->concat(self::$discoveredSerializableClasses)
69+
->unique()
70+
->values()
71+
->toArray(),
72+
]);
73+
}
74+
75+
protected function discoverSerializableClasses(): array
76+
{
77+
return collect([
78+
base_path("vendor/willdurand/geocoder/Model"),
79+
base_path("vendor/geocoder-php/*/Model"),
80+
])
81+
->flatMap(function (string $pattern): array {
82+
return glob($pattern)
83+
?: [];
84+
})
85+
->flatMap(function (string $directory): array {
86+
return glob("{$directory}/*.php")
87+
?: [];
88+
})
89+
->flatMap(function (string $file): array {
90+
return $this->classNamesFromVendorFile($file);
91+
})
92+
->prepend(Collection::class)
93+
->unique()
94+
->values()
95+
->toArray();
96+
}
97+
98+
protected function classNamesFromVendorFile(string $file): array
99+
{
100+
$contents = file_get_contents($file);
101+
102+
if ($contents === false) {
103+
return [];
104+
}
105+
106+
return $this->extractClassesFromTokens($this->tokenize($contents));
107+
}
108+
109+
protected function tokenize(string $contents): array
110+
{
111+
return array_values(array_filter(
112+
PhpToken::tokenize($contents),
113+
fn (PhpToken $token): bool => ! $token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]),
114+
));
115+
}
116+
117+
protected function extractClassesFromTokens(array $tokens): array
118+
{
119+
$namespace = "";
120+
$classes = [];
121+
122+
foreach ($tokens as $tokenIndex => $token) {
123+
if ($token->is(T_NAMESPACE)) {
124+
$namespace = $this->readNamespaceAt($tokens, $tokenIndex + 1);
125+
126+
continue;
127+
}
128+
129+
if (! $this->isClassDeclaration($tokens, $tokenIndex)) {
130+
continue;
131+
}
132+
133+
$classes[] = $this->qualify($namespace, $tokens[$tokenIndex + 1]->text);
134+
}
135+
136+
return $classes;
137+
}
138+
139+
protected function isClassDeclaration(array $tokens, int $tokenIndex): bool
140+
{
141+
if (
142+
! $tokens[$tokenIndex]->is(T_CLASS)
143+
|| (
144+
$tokenIndex > 0
145+
&& $tokens[$tokenIndex - 1]->is(T_NEW)
146+
)
147+
) {
148+
return false;
149+
}
150+
151+
return isset($tokens[$tokenIndex + 1])
152+
&& $tokens[$tokenIndex + 1]->is(T_STRING);
153+
}
154+
155+
protected function qualify(string $namespace, string $name): string
156+
{
157+
return $namespace !== ""
158+
? "{$namespace}\\{$name}"
159+
: $name;
160+
}
161+
162+
protected function readNamespaceAt(array $tokens, int $startingTokenIndex): string
163+
{
164+
$namespaceParts = [];
165+
$tokenCount = count($tokens);
166+
167+
for ($tokenIndex = $startingTokenIndex; $tokenIndex < $tokenCount; $tokenIndex++) {
168+
if (! $tokens[$tokenIndex]->is([T_STRING, T_NAME_QUALIFIED, T_NS_SEPARATOR])) {
169+
break;
170+
}
171+
172+
$namespaceParts[] = $tokens[$tokenIndex]->text;
173+
}
174+
175+
return implode("", $namespaceParts);
43176
}
44177

45-
protected function configPath(string $path = "") : string
178+
protected function configPath(string $path = ""): string
46179
{
47180
if (function_exists("config_path")) {
48181
return config_path($path);

tests/Feature/Providers/GeocoderServiceTest.php

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,121 @@
372372
expect($results->isNotEmpty())->toBeTrue();
373373
expect($store->get($hashedCacheKey)['value'])->toBeInstanceOf(Collection::class);
374374
});
375+
376+
it('discovers and registers base Geocoder model classes from vendor', function () {
377+
$registered = config('cache.serializable_classes');
378+
379+
expect($registered)->toBeArray();
380+
expect($registered)->toContain(Collection::class);
381+
expect($registered)->toContain(\Geocoder\Model\Address::class);
382+
expect($registered)->toContain(\Geocoder\Model\AdminLevel::class);
383+
expect($registered)->toContain(\Geocoder\Model\AdminLevelCollection::class);
384+
expect($registered)->toContain(\Geocoder\Model\Bounds::class);
385+
expect($registered)->toContain(\Geocoder\Model\Coordinates::class);
386+
expect($registered)->toContain(\Geocoder\Model\Country::class);
387+
});
388+
389+
it('discovers and registers installed provider model classes from vendor', function () {
390+
$registered = config('cache.serializable_classes');
391+
392+
expect($registered)->toContain(\Geocoder\Provider\Nominatim\Model\NominatimAddress::class);
393+
});
394+
395+
it('does not register provider classes whose package is not installed', function () {
396+
$registered = config('cache.serializable_classes');
397+
398+
expect(class_exists('Geocoder\\Provider\\BingMaps\\Model\\BingAddress'))->toBeFalse();
399+
expect($registered)->not->toContain('Geocoder\\Provider\\BingMaps\\Model\\BingAddress');
400+
});
401+
402+
it('produces no duplicate entries in cache.serializable_classes', function () {
403+
$registered = config('cache.serializable_classes');
404+
405+
expect($registered)->toBe(array_values(array_unique($registered)));
406+
});
407+
408+
it('preserves pre-existing cache.serializable_classes entries when merging', function () {
409+
config(['cache.serializable_classes' => [\stdClass::class]]);
410+
411+
$provider = new GeocoderService(app());
412+
$method = new \ReflectionMethod($provider, 'registerSerializableClasses');
413+
$method->setAccessible(true);
414+
$method->invoke($provider);
415+
416+
$registered = config('cache.serializable_classes');
417+
expect($registered)->toContain(\stdClass::class);
418+
expect($registered)->toContain(Collection::class);
419+
});
420+
421+
it('coerces a false cache.serializable_classes (Laravel 13 default) to an array', function () {
422+
config(['cache.serializable_classes' => false]);
423+
424+
$provider = new GeocoderService(app());
425+
$method = new \ReflectionMethod($provider, 'registerSerializableClasses');
426+
$method->setAccessible(true);
427+
$method->invoke($provider);
428+
429+
$registered = config('cache.serializable_classes');
430+
expect($registered)->toBeArray();
431+
expect($registered)->toContain(Collection::class);
432+
});
433+
434+
it('skips auto-registration when geocoder.cache.auto_register_serializable_classes is false', function () {
435+
config([
436+
'geocoder.cache.auto_register_serializable_classes' => false,
437+
'cache.serializable_classes' => false,
438+
]);
439+
440+
$provider = new GeocoderService(app());
441+
$method = new \ReflectionMethod($provider, 'registerSerializableClasses');
442+
$method->setAccessible(true);
443+
$method->invoke($provider);
444+
445+
expect(config('cache.serializable_classes'))->toBeFalse();
446+
});
447+
448+
it('extracts every class from a multi-class file', function () {
449+
$provider = new GeocoderService(app());
450+
$method = new \ReflectionMethod($provider, 'classNamesFromVendorFile');
451+
$method->setAccessible(true);
452+
453+
$result = $method->invoke(
454+
$provider,
455+
__DIR__ . '/../../Support/Fixtures/MultipleClasses.php'
456+
);
457+
458+
expect($result)->toBe([
459+
'Geocoder\\Laravel\\Tests\\Support\\Fixtures\\Multi\\First',
460+
'Geocoder\\Laravel\\Tests\\Support\\Fixtures\\Multi\\Second',
461+
]);
462+
});
463+
464+
it('handles bracketed namespace syntax', function () {
465+
$provider = new GeocoderService(app());
466+
$method = new \ReflectionMethod($provider, 'classNamesFromVendorFile');
467+
$method->setAccessible(true);
468+
469+
$result = $method->invoke(
470+
$provider,
471+
__DIR__ . '/../../Support/Fixtures/BracketedNamespace.php'
472+
);
473+
474+
expect($result)->toBe([
475+
'Geocoder\\Laravel\\Tests\\Support\\Fixtures\\Bracketed\\InsideBrackets',
476+
]);
477+
});
478+
479+
it('skips anonymous classes and string-literal class declarations', function () {
480+
$provider = new GeocoderService(app());
481+
$method = new \ReflectionMethod($provider, 'classNamesFromVendorFile');
482+
$method->setAccessible(true);
483+
484+
$result = $method->invoke(
485+
$provider,
486+
__DIR__ . '/../../Support/Fixtures/AnonymousAndStringLiteral.php'
487+
);
488+
489+
expect($result)->toBe([
490+
'Geocoder\\Laravel\\Tests\\Support\\Fixtures\\Tricky\\Real',
491+
]);
492+
});

0 commit comments

Comments
 (0)