Skip to content

Commit 50eb6cf

Browse files
committed
[FIX] Fix cache deserialization on Laravel 13 (serializable_classes)
Fixes: #1
1 parent be3a2aa commit 50eb6cf

3 files changed

Lines changed: 159 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## [4.1.1] - 2026-04-13
4+
5+
### Fixed
6+
7+
- **Cache serialization compatibility with Laravel 13.** Bumped `php-opcua/opcua-client` to `^4.1.1` which fixes `cachedFetch()` storing raw PHP objects in the PSR-16 cache. Laravel 13 defaults to `serializable_classes => false` in `config/cache.php`, causing all cached OPC UA types (`ReferenceDescription`, `NodeId`, `DataValue`, etc.) to be restored as `__PHP_Incomplete_Class` on cache hit. The fix wraps cached values as safe strings so the cache backend is immune to `allowed_classes` restrictions. ([#1](https://github.com/php-opcua/laravel-opcua/issues/1), [php-opcua/opcua-client#1](https://github.com/php-opcua/opcua-client/issues/1))
8+
9+
### Added
10+
11+
- Integration test `CacheSerializationTest` verifying browse results survive a file cache roundtrip across connections and that cached values are plain strings immune to `allowed_classes` restrictions.
12+
313
## [4.1.0] - 2026-04-13
414

515
### Added

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
],
1212
"require": {
1313
"php": "^8.2",
14-
"php-opcua/opcua-client": "^4.1",
14+
"php-opcua/opcua-client": "^4.1.1",
1515
"php-opcua/opcua-session-manager": "^4.1",
1616
"psr/event-dispatcher": "^1.0",
1717
"illuminate/support": "^11.0|^12.0|^13.0",
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PhpOpcua\LaravelOpcua\Tests\Integration\Helpers\TestHelper;
6+
use PhpOpcua\Client\Cache\FileCache;
7+
use PhpOpcua\Client\Types\ReferenceDescription;
8+
use PhpOpcua\Client\Types\NodeId;
9+
10+
beforeAll(fn() => TestHelper::startDaemon());
11+
afterAll(fn() => TestHelper::stopDaemon());
12+
13+
foreach (['direct' => 'createDirectManager', 'managed' => 'createManagedManager'] as $mode => $factory) {
14+
15+
describe("Issue #1 — cache serialization ({$mode} mode)", function () use ($factory, $mode) {
16+
17+
/**
18+
* Regression test for issue #1:
19+
* https://github.com/php-opcua/laravel-opcua/issues/1
20+
* https://github.com/php-opcua/opcua-client/issues/1
21+
*
22+
* Laravel 13 sets 'serializable_classes' => false in config/cache.php,
23+
* which causes unserialize() to be called with ['allowed_classes' => false].
24+
*
25+
* The fix in opcua-client v4.1.1 wraps cached values as safe strings
26+
* inside cachedFetch(), so the PSR-16 backend only ever stores plain
27+
* strings that are immune to allowed_classes restrictions.
28+
*
29+
* This test uses a FileCache (which serializes to disk) and verifies
30+
* that browse results survive a full cache roundtrip across two
31+
* separate connections (simulating two HTTP requests).
32+
*/
33+
it('browse results survive file cache roundtrip across connections', function () use ($factory) {
34+
$cacheDir = sys_get_temp_dir() . '/opcua-laravel-issue1-' . getmypid();
35+
$cache = new FileCache($cacheDir, 300);
36+
37+
try {
38+
// First connection: cache miss, fetches from server, stores in cache
39+
$manager1 = TestHelper::$factory([
40+
'connections' => [
41+
'default' => [
42+
'endpoint' => TestHelper::ENDPOINT_NO_SECURITY,
43+
'cache' => $cache,
44+
],
45+
],
46+
]);
47+
48+
$client1 = $manager1->connect();
49+
$refs1 = $client1->browse('i=85', useCache: true);
50+
expect($refs1)->toBeArray()->not->toBeEmpty();
51+
expect($refs1[0])->toBeInstanceOf(ReferenceDescription::class);
52+
$client1->disconnect();
53+
54+
// Second connection: cache hit, reads from file cache
55+
$manager2 = TestHelper::$factory([
56+
'connections' => [
57+
'default' => [
58+
'endpoint' => TestHelper::ENDPOINT_NO_SECURITY,
59+
'cache' => $cache,
60+
],
61+
],
62+
]);
63+
64+
$client2 = $manager2->connect();
65+
$refs2 = $client2->browse('i=85', useCache: true);
66+
67+
expect($refs2)->toBeArray()->not->toBeEmpty();
68+
expect($refs2[0])->toBeInstanceOf(ReferenceDescription::class);
69+
70+
foreach ($refs2 as $ref) {
71+
expect($ref->nodeId)->toBeInstanceOf(NodeId::class);
72+
expect($ref->browseName->name)->toBeString();
73+
expect($ref->displayName->text)->toBeString();
74+
expect($ref->isForward)->toBeBool();
75+
}
76+
77+
$names = array_map(fn($r) => $r->browseName->name, $refs2);
78+
expect($names)->toContain('Server');
79+
80+
$client2->disconnect();
81+
} finally {
82+
$cache->clear();
83+
if (is_dir($cacheDir)) {
84+
@rmdir($cacheDir);
85+
}
86+
TestHelper::safeDisconnect('default', $manager1 ?? null);
87+
TestHelper::safeDisconnect('default', $manager2 ?? null);
88+
}
89+
})->group('integration');
90+
91+
/**
92+
* Verifies that the wrapped cache value is a plain string that
93+
* survives unserialize() with allowed_classes=false (Laravel 13).
94+
*
95+
* Direct mode only: in managed mode the daemon handles caching
96+
* internally and the client-side FileCache is not populated.
97+
*/
98+
it('cached value is a plain string immune to allowed_classes restriction', function () use ($factory, $mode) {
99+
if ($mode === 'managed') {
100+
$this->markTestSkipped('Managed mode caches via daemon, not client-side FileCache');
101+
}
102+
$cacheDir = sys_get_temp_dir() . '/opcua-laravel-issue1-str-' . getmypid();
103+
$cache = new FileCache($cacheDir, 300);
104+
105+
try {
106+
$manager = TestHelper::$factory([
107+
'connections' => [
108+
'default' => [
109+
'endpoint' => TestHelper::ENDPOINT_NO_SECURITY,
110+
'cache' => $cache,
111+
],
112+
],
113+
]);
114+
115+
$client = $manager->connect();
116+
$client->browse('i=85', useCache: true);
117+
118+
// Read raw files from cache dir — the stored value must be a string
119+
$files = glob($cacheDir . '/*.cache') ?: [];
120+
expect($files)->not->toBeEmpty();
121+
122+
foreach ($files as $file) {
123+
$raw = file_get_contents($file);
124+
// FileCache stores: serialize(['value' => $wrapped, 'expiresAt' => ...])
125+
$entry = unserialize($raw);
126+
$value = $entry['value'];
127+
128+
// The wrapped value must be a plain string, not an object
129+
expect($value)->toBeString();
130+
131+
// Simulate Laravel 13: serialize/unserialize with restriction
132+
$afterLaravel = unserialize(serialize($value), ['allowed_classes' => false]);
133+
expect($afterLaravel)->toBe($value);
134+
}
135+
136+
$client->disconnect();
137+
} finally {
138+
$cache->clear();
139+
if (is_dir($cacheDir)) {
140+
@rmdir($cacheDir);
141+
}
142+
TestHelper::safeDisconnect('default', $manager ?? null);
143+
}
144+
})->group('integration');
145+
146+
})->group('integration');
147+
148+
}

0 commit comments

Comments
 (0)