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 f5aec3c..ae7bc7f 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/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 566d7c0..b1d0fe3 100644 --- a/src/OctaneServiceProvider.php +++ b/src/OctaneServiceProvider.php @@ -115,6 +115,8 @@ public function register() ? new SwooleCoroutineDispatcher($app->bound('Swoole\Http\Server')) : $app->make(SequentialCoroutineDispatcher::class); }); + + Octane::registerDevCommands(); } /** 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 4688440..c69f96a 100644 --- a/src/Swoole/Coroutine/CoroutineApplication.php +++ b/src/Swoole/Coroutine/CoroutineApplication.php @@ -1724,9 +1724,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/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/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/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); 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']); + } +} 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); 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)) {