Skip to content

Commit 119bf84

Browse files
authored
feat: ✨ Auto-register Geocoder model classes for Laravel 13 cache serialization. (#209)
* feat: ✨ Auto-register Geocoder model classes for Laravel 13 cache serialization. * Update GeocoderService.php
1 parent 68a3831 commit 119bf84

7 files changed

Lines changed: 402 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: 183 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,218 @@
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 Geocoder\Model\Address;
19+
use Illuminate\Support\Collection;
1420
use Illuminate\Support\ServiceProvider;
21+
use PhpToken;
22+
use ReflectionClass;
1523

1624
class GeocoderService extends ServiceProvider
1725
{
26+
// phpcs:ignore SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingAnyTypeHint
1827
protected $defer = false;
28+
protected static array $discoveredSerializableClasses = [];
1929

20-
public function boot()
30+
public function boot(): void
2131
{
2232
$configPath = __DIR__ . "/../../config/geocoder.php";
23-
$this->publishes(
24-
[$configPath => $this->configPath("geocoder.php")],
25-
"config"
26-
);
33+
$this->publishes([$configPath => $this->configPath("geocoder.php")], "config");
2734
$this->mergeConfigFrom($configPath, "geocoder");
35+
$this->registerSerializableClasses();
36+
}
37+
38+
public function provides(): array
39+
{
40+
return ["geocoder", ProviderAndDumperAggregator::class];
2841
}
2942

30-
public function register()
43+
public function register(): void
3144
{
3245
$this->app->alias("Geocoder", Geocoder::class);
3346
$this->app->singleton(ProviderAndDumperAggregator::class, function () {
3447
return (new ProviderAndDumperAggregator)
3548
->registerProvidersFromConfig(collect(config("geocoder.providers")));
3649
});
37-
$this->app->bind('geocoder', ProviderAndDumperAggregator::class);
50+
$this->app->bind("geocoder", ProviderAndDumperAggregator::class);
3851
}
3952

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

45-
protected function configPath(string $path = "") : string
215+
protected function configPath(string $path = ""): string
46216
{
47217
if (function_exists("config_path")) {
48218
return config_path($path);

0 commit comments

Comments
 (0)