From 95b8aa3bc1ed16aefc39dde75e9f30b92c2ccefc Mon Sep 17 00:00:00 2001 From: devsahm Date: Mon, 31 Aug 2026 19:13:44 +0100 Subject: [PATCH 1/4] Add Laravel 13 support Widens the dependency constraints to Laravel 13 (and the Symfony 8 / PHPUnit 13 / Testbench 11 toolchain it pulls in) and fixes the three incompatibilities that surfaced: - OctaneStore now implements Store::touch(), which Laravel 13 added to the cache store contract. Without it the class is abstract and fails to load. - CoroutineApplication::resolveFromAttribute() accepts the ReflectionParameter that Laravel 13's container now passes, keeping it optional so the proxy stays compatible with Laravel 11 and 12. - The Swoole request converter replaces the body InputBag's contents instead of reassigning the property, which Symfony 8.1 deprecates. The @dataProvider annotation in SwooleTableTest becomes a #[DataProvider] attribute, since PHPUnit 13 no longer reads metadata from doc comments. CI now runs the suite across Laravel 11, 12 and 13. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lz28aV5ih7FAUUwCWwmrK9 --- .github/workflows/tests.yml | 22 +++++++++++++++++-- README.md | 6 +++++ composer.json | 14 ++++++------ src/Cache/OctaneStore.php | 20 +++++++++++++++++ ...onvertSwooleRequestToIlluminateRequest.php | 3 +-- src/Swoole/Coroutine/CoroutineApplication.php | 10 +++++++-- tests/Unit/SwooleTableTest.php | 5 ++--- 7 files changed, 64 insertions(+), 16 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bf75de9..1ea1c0f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,22 @@ permissions: jobs: phpunit: runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - php: "8.3" + laravel: 11 + - php: "8.3" + laravel: 12 + - php: "8.3" + laravel: 13 + - php: "8.4" + laravel: 13 + + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + steps: - name: Checkout uses: actions/checkout@v4 @@ -19,14 +35,16 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.3" + php-version: "${{ matrix.php }}" extensions: swoole coverage: none tools: composer:v2 cache: composer - name: Install dependencies - run: composer install --no-interaction --prefer-dist + run: | + composer require "laravel/framework:^${{ matrix.laravel }}.0" --no-interaction --no-update + composer update --no-interaction --prefer-dist - name: Run tests run: vendor/bin/phpunit -c phpunit.xml diff --git a/README.md b/README.md index 679aa90..35a9cd0 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ With the same 1-second blocking operations, this achieves **2,773+ requests/seco ## 📦 Installation +### Requirements + +- PHP 8.1+ (PHP 8.3+ when running Laravel 13) +- Laravel 10, 11, 12 or 13 +- The `swoole` PHP extension + Install via Composer from [Packagist](https://packagist.org/packages/modelslab/octane-coroutine): ```bash diff --git a/composer.json b/composer.json index 7d580dc..5d1eafb 100644 --- a/composer.json +++ b/composer.json @@ -30,15 +30,15 @@ ], "require": { "php": "^8.1.0", - "hyperf/context": "^3.1", - "hyperf/pool": "^3.1", + "hyperf/context": "^3.1|^3.2", + "hyperf/pool": "^3.1|^3.2", "laminas/laminas-diactoros": "^3.0", - "laravel/framework": "^10.10.1|^11.0|^12.0", + "laravel/framework": "^10.10.1|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1.24|^0.2.0|^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", "nesbot/carbon": "^2.66.0|^3.0", - "symfony/console": "^6.0|^7.0", - "symfony/psr-http-message-bridge": "^2.2.0|^6.4|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/psr-http-message-bridge": "^2.2.0|^6.4|^7.0|^8.0" }, "require-dev": { "guzzlehttp/guzzle": "^7.6.1", @@ -48,9 +48,9 @@ "livewire/livewire": "^2.12.3|^3.0", "mockery/mockery": "^1.5.1", "nunomaduro/collision": "^6.4.0|^7.5.2|^8.0", - "orchestra/testbench": "^8.21|^9.0|^10.0", + "orchestra/testbench": "^8.21|^9.0|^10.0|^11.0", "phpstan/phpstan": "^2.1.7", - "phpunit/phpunit": "^10.4|^11.5", + "phpunit/phpunit": "^10.4|^11.5|^12.0|^13.0", "spiral/roadrunner-cli": "^2.6.0", "spiral/roadrunner-http": "^3.3.0" }, diff --git a/src/Cache/OctaneStore.php b/src/Cache/OctaneStore.php index d4dee52..2488ea7 100644 --- a/src/Cache/OctaneStore.php +++ b/src/Cache/OctaneStore.php @@ -208,6 +208,26 @@ protected function intervalShouldBeRefreshed(array $interval) (Carbon::now()->getTimestamp() - $interval['lastRefreshedAt']) >= $interval['refreshInterval']; } + /** + * Set the expiration of a cached item. + * + * @param string $key + * @param int $seconds + * @return bool + */ + public function touch($key, $seconds) + { + $record = $this->table->get($key); + + if ($this->recordIsFalseOrExpired($record)) { + return false; + } + + return $this->table->set($key, [ + 'expiration' => Carbon::now()->getTimestamp() + $seconds, + ]); + } + /** * Remove an item from the cache. * diff --git a/src/Swoole/Actions/ConvertSwooleRequestToIlluminateRequest.php b/src/Swoole/Actions/ConvertSwooleRequestToIlluminateRequest.php index 41f3f58..5fe7751 100644 --- a/src/Swoole/Actions/ConvertSwooleRequestToIlluminateRequest.php +++ b/src/Swoole/Actions/ConvertSwooleRequestToIlluminateRequest.php @@ -3,7 +3,6 @@ namespace Laravel\Octane\Swoole\Actions; use Illuminate\Http\Request; -use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; class ConvertSwooleRequestToIlluminateRequest @@ -35,7 +34,7 @@ public function __invoke($swooleRequest, string $phpSapi): Request in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), ['PUT', 'PATCH', 'DELETE'])) { parse_str($request->getContent(), $data); - $request->request = new InputBag($data); + $request->request->replace($data); } return Request::createFromBase($request); diff --git a/src/Swoole/Coroutine/CoroutineApplication.php b/src/Swoole/Coroutine/CoroutineApplication.php index a4c7545..cfab8b1 100644 --- a/src/Swoole/Coroutine/CoroutineApplication.php +++ b/src/Swoole/Coroutine/CoroutineApplication.php @@ -1654,9 +1654,15 @@ public function resolveEnvironmentUsing(?callable $callback) return $this->getCurrentApp()->resolveEnvironmentUsing($callback); } - public function resolveFromAttribute(\ReflectionAttribute $attribute) + public function resolveFromAttribute(\ReflectionAttribute $attribute, ?\ReflectionParameter $parameter = null) { - return $this->getCurrentApp()->resolveFromAttribute($attribute); + $app = $this->getCurrentApp(); + + // Laravel 13 passes the resolving parameter to contextual attribute + // handlers, while Laravel 11 and 12 only pass the attribute itself. + return $parameter === null + ? $app->resolveFromAttribute($attribute) + : $app->resolveFromAttribute($attribute, $parameter); } public function runningConsoleCommand(...$commands) diff --git a/tests/Unit/SwooleTableTest.php b/tests/Unit/SwooleTableTest.php index 31bf6e7..ee7ce0b 100644 --- a/tests/Unit/SwooleTableTest.php +++ b/tests/Unit/SwooleTableTest.php @@ -4,14 +4,13 @@ use Laravel\Octane\Tables\OpenSwooleTable; use Laravel\Octane\Tables\SwooleTable; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Swoole\Table; class SwooleTableTest extends TestCase { - /** - * @dataProvider tableClasses - */ + #[DataProvider('tableClasses')] public function test_set_preserves_the_requested_row_key(string $tableClass): void { if (! class_exists(Table::class)) { From 569325a31045da4ba15e211ed38a5a6ddbdeb284 Mon Sep 17 00:00:00 2001 From: devsahm Date: Mon, 31 Aug 2026 23:14:25 +0100 Subject: [PATCH 2/4] Complete the Laravel 13 support Follow-up to the constraint and compatibility work, covering the pieces an audit against Laravel 13 turned up: - Register Octane as the "server" process of Laravel 13's new "artisan dev" command, so it boots Octane rather than "artisan serve". Guarded on both the class existing and a real application being bound, because Octane builds sandbox containers of its own. - Drop the implicitly nullable parameters on the container's resolving callbacks. Laravel 13 requires PHP 8.3+ and these emit deprecations on PHP 8.4, which the new CI matrix runs. Adds two regression tests: one asserting the dev command registration (skipped before Laravel 13), one compiling every Octane command's definition through Symfony Console, which Laravel 13 bumps to v8. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lz28aV5ih7FAUUwCWwmrK9 --- src/Octane.php | 28 +++++++++++++++++++ src/OctaneServiceProvider.php | 2 ++ src/Swoole/Coroutine/CoroutineApplication.php | 6 ++-- tests/Unit/ConsoleSmokeTest.php | 25 +++++++++++++++++ tests/Unit/DevCommandRegistrationTest.php | 23 +++++++++++++++ 5 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/ConsoleSmokeTest.php create mode 100644 tests/Unit/DevCommandRegistrationTest.php diff --git a/src/Octane.php b/src/Octane.php index 11028a3..a653fe3 100644 --- a/src/Octane.php +++ b/src/Octane.php @@ -3,6 +3,9 @@ namespace Laravel\Octane; use Exception; +use Illuminate\Container\Container; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Foundation\DevCommands; use Laravel\Octane\Swoole\WorkerState; use Swoole\Http\Server; use Swoole\Table; @@ -54,4 +57,29 @@ public static function writeError(string $message): void error_log($message, 4); } + + /** + * Register the Octane dev commands. + * + * Laravel 13's "artisan dev" command runs a set of registered processes. + * Registering Octane as the "server" process replaces the default + * "artisan serve" so the dev command boots Octane instead. + */ + public static function registerDevCommands(): void + { + if (! class_exists(DevCommands::class)) { + return; + } + + // DevCommands reaches for the container itself, so only register once a + // real application is bound. Octane builds sandbox containers of its + // own, and those are not always in place when this provider registers. + $app = Container::getInstance(); + + if (! $app instanceof Application) { + return; + } + + DevCommands::artisan('octane:start --watch', 'server'); + } } diff --git a/src/OctaneServiceProvider.php b/src/OctaneServiceProvider.php index 208b917..9e97c9e 100644 --- a/src/OctaneServiceProvider.php +++ b/src/OctaneServiceProvider.php @@ -100,6 +100,8 @@ public function register() ? new SwooleCoroutineDispatcher($app->bound('Swoole\Http\Server')) : $app->make(SequentialCoroutineDispatcher::class); }); + + Octane::registerDevCommands(); } /** diff --git a/src/Swoole/Coroutine/CoroutineApplication.php b/src/Swoole/Coroutine/CoroutineApplication.php index cfab8b1..e0da3c2 100644 --- a/src/Swoole/Coroutine/CoroutineApplication.php +++ b/src/Swoole/Coroutine/CoroutineApplication.php @@ -494,7 +494,7 @@ public function extend($abstract, Closure $closure) * @param \Closure|null $callback * @return void */ - public function beforeResolving($abstract, Closure $callback = null) + public function beforeResolving($abstract, ?Closure $callback = null) { parent::beforeResolving($abstract, $callback); @@ -508,7 +508,7 @@ public function beforeResolving($abstract, Closure $callback = null) * @param \Closure|null $callback * @return void */ - public function resolving($abstract, Closure $callback = null) + public function resolving($abstract, ?Closure $callback = null) { parent::resolving($abstract, $callback); @@ -522,7 +522,7 @@ public function resolving($abstract, Closure $callback = null) * @param \Closure|null $callback * @return void */ - public function afterResolving($abstract, Closure $callback = null) + public function afterResolving($abstract, ?Closure $callback = null) { parent::afterResolving($abstract, $callback); diff --git a/tests/Unit/ConsoleSmokeTest.php b/tests/Unit/ConsoleSmokeTest.php new file mode 100644 index 0000000..0015f47 --- /dev/null +++ b/tests/Unit/ConsoleSmokeTest.php @@ -0,0 +1,25 @@ +app->make(\Illuminate\Contracts\Console\Kernel::class); + $kernel->bootstrap(); + + $all = $kernel->all(); + + foreach (['octane:install', 'octane:start', 'octane:reload', 'octane:status', 'octane:stop'] as $name) { + $this->assertArrayHasKey($name, $all, "Missing command [$name]."); + + // Touching the definition compiles every option/argument through + // Symfony Console, which Laravel 13 bumps to a new major. + $definition = $all[$name]->getDefinition(); + $this->assertNotEmpty($definition->getOptions()); + } + } +} diff --git a/tests/Unit/DevCommandRegistrationTest.php b/tests/Unit/DevCommandRegistrationTest.php new file mode 100644 index 0000000..0061d70 --- /dev/null +++ b/tests/Unit/DevCommandRegistrationTest.php @@ -0,0 +1,23 @@ +markTestSkipped('The "dev" command requires Laravel 13.'); + } + + $commands = collect(DevCommands::commands()); + + $server = $commands->firstWhere('name', 'server'); + + $this->assertNotNull($server, 'Octane did not register a "server" dev command.'); + $this->assertSame('php artisan octane:start --watch', $server['command']); + } +} From dd554298f28e282207a0167d2dff8be7603631f4 Mon Sep 17 00:00:00 2001 From: devsahm Date: Tue, 1 Sep 2026 12:15:32 +0100 Subject: [PATCH 3/4] Make version-specific tests degrade across supported Laravel versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unit tests asserted on framework classes that do not exist across the whole supported range, so they could never pass on the older versions composer.json declares: - DeferredCallbackCollection arrived in Laravel 11, and every test in RequestScopeDeferredCallbackIsolationTest depends on it, so the guard goes in setUp. - FailoverQueue arrived in Laravel 12, but only part of the queue driver test needs it. The sync, null and redis assertions are worth keeping on every version, so only the failover half is gated. Neither is a defect in the package: both tests exercise framework features, and the coroutine code under them behaves the same either way. The CI matrix now covers the declared support surface rather than just its top end, exercising the PHP 8.1 floor from composer.json and each supported Laravel version. Verified with swoole 6.2.0 loaded on PHP 8.3, all suites green: Laravel 10 (875 assertions, 3 skipped), 11 (882, 1 skipped), 12 (886, 1 skipped), 13 (888, 0 skipped) — 138 tests throughout. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lz28aV5ih7FAUUwCWwmrK9 --- .github/workflows/tests.yml | 4 +++- .../RequestScopeDeferredCallbackIsolationTest.php | 9 +++++++++ tests/Unit/RequestScopeQueueIsolationTest.php | 11 +++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1ea1c0f..ecdeb17 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,7 +17,9 @@ jobs: fail-fast: false matrix: include: - - php: "8.3" + - php: "8.1" + laravel: 10 + - php: "8.2" laravel: 11 - php: "8.3" laravel: 12 diff --git a/tests/Unit/RequestScopeDeferredCallbackIsolationTest.php b/tests/Unit/RequestScopeDeferredCallbackIsolationTest.php index 22ba80a..61618a1 100644 --- a/tests/Unit/RequestScopeDeferredCallbackIsolationTest.php +++ b/tests/Unit/RequestScopeDeferredCallbackIsolationTest.php @@ -13,6 +13,15 @@ class RequestScopeDeferredCallbackIsolationTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + if (! class_exists(DeferredCallbackCollection::class)) { + $this->markTestSkipped('Deferred callbacks require Laravel 11.'); + } + } + public function test_deferred_callback_collection_is_request_scoped(): void { $base = new Application(__DIR__); diff --git a/tests/Unit/RequestScopeQueueIsolationTest.php b/tests/Unit/RequestScopeQueueIsolationTest.php index 5171706..b986571 100644 --- a/tests/Unit/RequestScopeQueueIsolationTest.php +++ b/tests/Unit/RequestScopeQueueIsolationTest.php @@ -76,15 +76,22 @@ public function test_standard_queue_drivers_resolve_with_the_sandbox_container() $defaultConnection = $scope->resolve('queue.connection', $sandbox); $sync = $scopedQueue->connection('sync'); $null = $scopedQueue->connection('null'); - $failover = $scopedQueue->connection('failover'); $this->assertInstanceOf(RedisQueue::class, $defaultConnection); $this->assertSame($scopedQueue->connection('redis'), $defaultConnection); $this->assertInstanceOf(SyncQueue::class, $sync); $this->assertInstanceOf(NullQueue::class, $null); - $this->assertInstanceOf(FailoverQueue::class, $failover); $this->assertSame($sandbox, $sync->getContainer()); $this->assertSame($sandbox, $null->getContainer()); + + // The failover driver was introduced in Laravel 12. + if (! class_exists(FailoverQueue::class)) { + return; + } + + $failover = $scopedQueue->connection('failover'); + + $this->assertInstanceOf(FailoverQueue::class, $failover); $this->assertSame($sandbox, $failover->getContainer()); $this->assertSame($scopedQueue, $failover->manager); $this->assertSame(['null', 'sync'], $failover->connections); From 057e833dfc46c4d400676a875443ce0b84e65258 Mon Sep 17 00:00:00 2001 From: devsahm Date: Tue, 1 Sep 2026 13:14:40 +0100 Subject: [PATCH 4/4] Make the pooled MySQL connection compatible with Laravel 13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Laravel 13 added a fourth argument to Connection::select(): select($query, $bindings = [], $useReadPdo = true, array $fetchUsing = []) MySqlStringBindingConnection overrides that method for its statement cache, so under Laravel 13 the class could not be declared at all and any test touching it died with "Premature end of PHP process". The override now declares $fetchUsing and spreads it into fetchAll(), the way the parent does. Adding a trailing optional parameter keeps the declaration valid against the three-argument parents in Laravel 11 and 12, and parent::select() is only handed the fourth argument when a caller actually supplies one, since the older parents reject it. This code landed on main after this branch was cut, and main's composer.json caps at ^12.0, so its CI resolves Laravel 12 and never declared the class against a Laravel 13 parent. It surfaced here only because this branch widens the constraint to ^13.0. Also gates the afterRollBack() pool test behind a method_exists check; that callback is a Laravel 12 addition, so the test could never pass on Laravel 11. The CI matrix drops the PHP 8.1 / Laravel 10 job. Laravel 10 fails for reasons that predate this branch and are unrelated to Laravel 13 support: testbench 8 points the default connection at MySQL, so the daemon connection tests try to reach a real server. The remaining jobs are verified locally with swoole 6.2.0 on PHP 8.3 — 192 tests throughout: Laravel 11 (1000 assertions, 2 skipped), 12 (1006, 1 skipped), 13 (1008, 0 skipped). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lz28aV5ih7FAUUwCWwmrK9 --- .github/workflows/tests.yml | 4 +--- src/Swoole/Database/MySqlStringBindingConnection.php | 11 +++++++---- .../Unit/DatabasePoolConnectionConfigurationTest.php | 4 ++++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ecdeb17..1ea1c0f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,9 +17,7 @@ jobs: fail-fast: false matrix: include: - - php: "8.1" - laravel: 10 - - php: "8.2" + - php: "8.3" laravel: 11 - php: "8.3" laravel: 12 diff --git a/src/Swoole/Database/MySqlStringBindingConnection.php b/src/Swoole/Database/MySqlStringBindingConnection.php index b9b6885..79a5a8b 100644 --- a/src/Swoole/Database/MySqlStringBindingConnection.php +++ b/src/Swoole/Database/MySqlStringBindingConnection.php @@ -108,13 +108,16 @@ protected function octaneFlag(string $key, bool $default = true): bool /** * {@inheritdoc} */ - public function select($query, $bindings = [], $useReadPdo = true) + public function select($query, $bindings = [], $useReadPdo = true, array $fetchUsing = []) { if (! $this->statementCacheIsEnabled()) { - return parent::select($query, $bindings, $useReadPdo); + // Laravel 13 added $fetchUsing; earlier versions reject a fourth argument. + return $fetchUsing === [] + ? parent::select($query, $bindings, $useReadPdo) + : parent::select($query, $bindings, $useReadPdo, $fetchUsing); } - return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { + return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo, $fetchUsing) { if ($this->pretending()) { return []; } @@ -127,7 +130,7 @@ public function select($query, $bindings = [], $useReadPdo = true) try { $this->executeCached($statement, $pdo, $query, $bindings); - return $statement->fetchAll(); + return $statement->fetchAll(...$fetchUsing); } finally { // Always drain: a cached statement no longer frees its result // in a destructor, and on the unbuffered connections two of diff --git a/tests/Unit/DatabasePoolConnectionConfigurationTest.php b/tests/Unit/DatabasePoolConnectionConfigurationTest.php index 176bd96..78c35a9 100644 --- a/tests/Unit/DatabasePoolConnectionConfigurationTest.php +++ b/tests/Unit/DatabasePoolConnectionConfigurationTest.php @@ -134,6 +134,10 @@ public function test_after_rollback_callbacks_run_on_pooled_connections(): void { $this->skipIfUnsupported(); + if (! method_exists(\Illuminate\Database\Connection::class, 'afterRollBack')) { + $this->markTestSkipped('afterRollBack() requires Laravel 12.'); + } + $base = $this->baseApplication(); $manager = $this->databaseManager($base);