From fde4f2c52a6445a36fc7e5d3ec56366e3753445c Mon Sep 17 00:00:00 2001 From: Micilini Roll Date: Sat, 9 May 2026 20:14:21 -0300 Subject: [PATCH] phase 13: add laravel integration --- README.md | 46 ++++++ composer.json | 20 ++- config/phpsockets.php | 73 ++++++++++ src/Laravel/Commands/MigrateCommand.php | 65 +++++++++ src/Laravel/Commands/ServeCommand.php | 55 ++++++++ src/Laravel/Commands/StatusCommand.php | 36 +++++ src/Laravel/PhpSocketsFacade.php | 24 ++++ src/Laravel/PhpSocketsManager.php | 131 ++++++++++++++++++ src/Laravel/PhpSocketsServiceProvider.php | 56 ++++++++ .../Laravel/ArtisanCommandsTest.php | 56 ++++++++ .../Laravel/ServiceProviderTest.php | 61 ++++++++ 11 files changed, 620 insertions(+), 3 deletions(-) create mode 100644 config/phpsockets.php create mode 100644 src/Laravel/Commands/MigrateCommand.php create mode 100644 src/Laravel/Commands/ServeCommand.php create mode 100644 src/Laravel/Commands/StatusCommand.php create mode 100644 src/Laravel/PhpSocketsFacade.php create mode 100644 src/Laravel/PhpSocketsManager.php create mode 100644 src/Laravel/PhpSocketsServiceProvider.php create mode 100644 tests/Integration/Laravel/ArtisanCommandsTest.php create mode 100644 tests/Integration/Laravel/ServiceProviderTest.php diff --git a/README.md b/README.md index f26131b..42688e3 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,52 @@ hello Bots are intentionally simple in v1. They do not call external AI APIs or run asynchronous jobs. +## Laravel integration + +PHPSockets can be installed inside Laravel applications through Composer package discovery. + +Publish the config: + +```bash +php artisan vendor:publish --tag=phpsockets-config +``` + +Check the package status: + +```bash +php artisan phpsockets:status +``` + +Run SQLite migrations: + +```bash +php artisan phpsockets:migrate --driver=sqlite +``` + +Start the WebSocket chat server from Laravel: + +```bash +php artisan phpsockets:serve +``` + +The package registers: + +- `Micilini\PhpSockets\Laravel\PhpSocketsServiceProvider` +- `Micilini\PhpSockets\Laravel\PhpSocketsFacade` +- `phpsockets:serve` +- `phpsockets:migrate` +- `phpsockets:status` + +Example usage: + +```php +use Micilini\PhpSockets\Laravel\PhpSocketsFacade as PhpSockets; + +PhpSockets::bots(); +``` + +Laravel is optional. The native PHP core continues to work standalone. + ## Emoji and small attachment support The chat examples support a composer action button next to the message input. diff --git a/composer.json b/composer.json index 5f059d9..80abe64 100644 --- a/composer.json +++ b/composer.json @@ -19,12 +19,16 @@ "require-dev": { "phpunit/phpunit": "^10.0|^11.0", "phpstan/phpstan": "^1.10|^2.0", - "friendsofphp/php-cs-fixer": "^3.0" + "friendsofphp/php-cs-fixer": "^3.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0" }, "suggest": { "ext-pdo": "Required for SQL storage adapters and migrations.", "ext-pdo_sqlite": "Required for SQLite storage tests and local persistence.", - "illuminate/support": "Required for Laravel integration." + "illuminate/support": "Required for Laravel service provider, facade and config integration.", + "illuminate/console": "Required for Laravel Artisan commands." }, "autoload": { "psr-4": { @@ -48,5 +52,15 @@ ] }, "minimum-stability": "stable", - "prefer-stable": true + "prefer-stable": true, + "extra": { + "laravel": { + "providers": [ + "Micilini\\PhpSockets\\Laravel\\PhpSocketsServiceProvider" + ], + "aliases": { + "PhpSockets": "Micilini\\PhpSockets\\Laravel\\PhpSocketsFacade" + } + } + } } diff --git a/config/phpsockets.php b/config/phpsockets.php new file mode 100644 index 0000000..f9782c7 --- /dev/null +++ b/config/phpsockets.php @@ -0,0 +1,73 @@ + [ + 'host' => env('PHPSOCKETS_HOST', '127.0.0.1'), + 'port' => (int) env('PHPSOCKETS_PORT', 8080), + 'max_payload_bytes' => (int) env('PHPSOCKETS_MAX_PAYLOAD_BYTES', 4 * 1024 * 1024), + 'tick_microseconds' => (int) env('PHPSOCKETS_TICK_MICROSECONDS', 10000), + 'connection_limit' => (int) env('PHPSOCKETS_CONNECTION_LIMIT', 100), + 'debug' => (bool) env('PHPSOCKETS_DEBUG', false), + ], + + /* + |-------------------------------------------------------------------------- + | PHPSockets Chat + |-------------------------------------------------------------------------- + */ + + 'chat' => [ + 'max_display_name_length' => (int) env('PHPSOCKETS_MAX_DISPLAY_NAME_LENGTH', 40), + 'max_room_name_length' => (int) env('PHPSOCKETS_MAX_ROOM_NAME_LENGTH', 80), + 'max_private_group_members' => (int) env('PHPSOCKETS_MAX_PRIVATE_GROUP_MEMBERS', 20), + 'allow_guest_sessions' => (bool) env('PHPSOCKETS_ALLOW_GUEST_SESSIONS', true), + 'history_limit' => (int) env('PHPSOCKETS_HISTORY_LIMIT', 50), + 'max_attachment_bytes' => (int) env('PHPSOCKETS_MAX_ATTACHMENT_BYTES', 2 * 1024 * 1024), + 'max_attachment_file_name_length' => (int) env('PHPSOCKETS_MAX_ATTACHMENT_FILE_NAME_LENGTH', 180), + + 'allowed_attachment_mime_types' => [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'application/pdf', + 'text/plain', + ], + ], + + /* + |-------------------------------------------------------------------------- + | PHPSockets Storage + |-------------------------------------------------------------------------- + | + | memory: + | Default runtime storage. + | + | sqlite/mysql/pgsql: + | Used by migrations and future persistent Laravel examples. + | + */ + + 'storage' => [ + 'driver' => env('PHPSOCKETS_STORAGE', 'memory'), + + 'database' => env('PHPSOCKETS_DATABASE', database_path('phpsockets.sqlite')), + + 'dsn' => env('PHPSOCKETS_DSN'), + + 'username' => env('PHPSOCKETS_DB_USERNAME'), + + 'password' => env('PHPSOCKETS_DB_PASSWORD'), + ], +]; diff --git a/src/Laravel/Commands/MigrateCommand.php b/src/Laravel/Commands/MigrateCommand.php new file mode 100644 index 0000000..c86c087 --- /dev/null +++ b/src/Laravel/Commands/MigrateCommand.php @@ -0,0 +1,65 @@ +option('driver'); + $driver = is_string($driver) && $driver !== '' + ? strtolower(trim($driver)) + : $manager->storageDriver(); + + if ($driver === 'memory') { + $this->components->warn('The memory storage driver does not need migrations.'); + + return self::SUCCESS; + } + + if (!in_array($driver, ['sqlite', 'mysql', 'pgsql'], true)) { + $this->components->error("Unsupported migration driver: {$driver}"); + + return self::FAILURE; + } + + if ($this->laravel->environment('production') && !$this->option('force')) { + if (!$this->confirm('You are running PHPSockets migrations in production. Continue?')) { + return self::FAILURE; + } + } + + $database = $this->option('database'); + + try { + $pdo = $manager->pdo( + driver: $driver, + databaseOverride: is_string($database) && $database !== '' ? $database : null, + ); + + (new MigrationRunner($pdo))->run($driver); + + $this->components->info("PHPSockets {$driver} migrations completed."); + + return self::SUCCESS; + } catch (Throwable $exception) { + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/src/Laravel/Commands/ServeCommand.php b/src/Laravel/Commands/ServeCommand.php new file mode 100644 index 0000000..31680cd --- /dev/null +++ b/src/Laravel/Commands/ServeCommand.php @@ -0,0 +1,55 @@ +option('host'); + + if (is_string($host) && $host !== '') { + $overrides['host'] = $host; + } + + $port = $this->option('port'); + + if (is_string($port) && $port !== '') { + $overrides['port'] = (int) $port; + } + + if ((bool) $this->option('debug')) { + $overrides['debug'] = true; + } + + try { + $serverConfig = $manager->serverConfig($overrides); + $server = $manager->server($overrides); + + $this->components->info("Starting PHPSockets on {$serverConfig->host}:{$serverConfig->port}"); + $this->line('Press CTRL+C to stop.'); + + $server->run(); + + return self::SUCCESS; + } catch (Throwable $exception) { + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/src/Laravel/Commands/StatusCommand.php b/src/Laravel/Commands/StatusCommand.php new file mode 100644 index 0000000..03c4228 --- /dev/null +++ b/src/Laravel/Commands/StatusCommand.php @@ -0,0 +1,36 @@ +serverConfig(); + $chatConfig = $manager->chatConfig(); + + $this->components->info('PHPSockets is installed.'); + + $this->table(['Option', 'Value'], [ + ['Host', $serverConfig->host], + ['Port', (string) $serverConfig->port], + ['Max payload bytes', (string) $serverConfig->maxPayloadBytes], + ['Connection limit', (string) $serverConfig->connectionLimit], + ['Debug logs', $serverConfig->enableDebugLogs ? 'yes' : 'no'], + ['History limit', (string) $chatConfig->historyLimit], + ['Max attachment bytes', (string) $chatConfig->maxAttachmentBytes], + ['Storage driver', $manager->storageDriver()], + ]); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/PhpSocketsFacade.php b/src/Laravel/PhpSocketsFacade.php new file mode 100644 index 0000000..3022820 --- /dev/null +++ b/src/Laravel/PhpSocketsFacade.php @@ -0,0 +1,24 @@ + $overrides + */ + public function serverConfig(array $overrides = []): ServerConfig + { + $server = $this->config->get('phpsockets.server', []); + + if (!is_array($server)) { + $server = []; + } + + $server = array_merge($server, $overrides); + + return ServerConfig::new( + host: (string) ($server['host'] ?? '127.0.0.1'), + port: (int) ($server['port'] ?? 8080), + maxPayloadBytes: (int) ($server['max_payload_bytes'] ?? 4 * 1024 * 1024), + tickMicroseconds: (int) ($server['tick_microseconds'] ?? 10000), + connectionLimit: (int) ($server['connection_limit'] ?? 100), + enableDebugLogs: (bool) ($server['debug'] ?? false), + ); + } + + public function chatConfig(): ChatConfig + { + $chat = $this->config->get('phpsockets.chat', []); + + if (!is_array($chat)) { + $chat = []; + } + + $allowedMimeTypes = $chat['allowed_attachment_mime_types'] ?? null; + + return ChatConfig::new( + maxDisplayNameLength: (int) ($chat['max_display_name_length'] ?? 40), + maxRoomNameLength: (int) ($chat['max_room_name_length'] ?? 80), + maxPrivateGroupMembers: (int) ($chat['max_private_group_members'] ?? 20), + allowGuestSessions: (bool) ($chat['allow_guest_sessions'] ?? true), + historyLimit: (int) ($chat['history_limit'] ?? 50), + maxAttachmentBytes: (int) ($chat['max_attachment_bytes'] ?? 2 * 1024 * 1024), + maxAttachmentFileNameLength: (int) ($chat['max_attachment_file_name_length'] ?? 180), + allowedAttachmentMimeTypes: is_array($allowedMimeTypes) ? array_values($allowedMimeTypes) : null, + ); + } + + /** + * @param array $serverOverrides + */ + public function server(array $serverOverrides = []): ChatServer + { + return ChatServer::create( + serverConfig: $this->serverConfig($serverOverrides), + chatConfig: $this->chatConfig(), + ); + } + + public function storageDriver(?string $override = null): string + { + $driver = $override ?? $this->config->get('phpsockets.storage.driver', 'memory'); + + return strtolower(trim((string) $driver)); + } + + public function pdo(?string $driver = null, ?string $databaseOverride = null): PDO + { + $driver = $this->storageDriver($driver); + + if ($driver === 'sqlite') { + $database = $databaseOverride ?: (string) $this->config->get('phpsockets.storage.database'); + + if ($database === '') { + throw new RuntimeException('SQLite database path is required.'); + } + + $directory = dirname($database); + + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException("Unable to create SQLite database directory: {$directory}"); + } + + return PdoConnectionFactory::sqlite($database); + } + + if (in_array($driver, ['mysql', 'pgsql'], true)) { + $dsn = (string) $this->config->get('phpsockets.storage.dsn', ''); + + if ($dsn === '') { + throw new RuntimeException("A PDO DSN is required for {$driver} storage."); + } + + return PdoConnectionFactory::create( + dsn: $dsn, + username: $this->nullableString($this->config->get('phpsockets.storage.username')), + password: $this->nullableString($this->config->get('phpsockets.storage.password')), + ); + } + + throw new RuntimeException("Storage driver {$driver} does not use PDO."); + } + + private function nullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $value = (string) $value; + + return $value === '' ? null : $value; + } +} diff --git a/src/Laravel/PhpSocketsServiceProvider.php b/src/Laravel/PhpSocketsServiceProvider.php new file mode 100644 index 0000000..2f448d5 --- /dev/null +++ b/src/Laravel/PhpSocketsServiceProvider.php @@ -0,0 +1,56 @@ +mergeConfigFrom(__DIR__ . '/../../config/phpsockets.php', 'phpsockets'); + + $this->app->singleton(PhpSocketsManager::class, static function ($app): PhpSocketsManager { + return new PhpSocketsManager($app['config']); + }); + + $this->app->alias(PhpSocketsManager::class, 'phpsockets.manager'); + + $this->app->bind(ServerConfig::class, static function ($app): ServerConfig { + return $app->make(PhpSocketsManager::class)->serverConfig(); + }); + + $this->app->bind(ChatConfig::class, static function ($app): ChatConfig { + return $app->make(PhpSocketsManager::class)->chatConfig(); + }); + + $this->app->bind(ChatServer::class, static function ($app): ChatServer { + return $app->make(PhpSocketsManager::class)->server(); + }); + + $this->app->alias(ChatServer::class, 'phpsockets'); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../../config/phpsockets.php' => config_path('phpsockets.php'), + ], 'phpsockets-config'); + + if ($this->app->runningInConsole()) { + $this->commands([ + ServeCommand::class, + MigrateCommand::class, + StatusCommand::class, + ]); + } + } +} diff --git a/tests/Integration/Laravel/ArtisanCommandsTest.php b/tests/Integration/Laravel/ArtisanCommandsTest.php new file mode 100644 index 0000000..480a715 --- /dev/null +++ b/tests/Integration/Laravel/ArtisanCommandsTest.php @@ -0,0 +1,56 @@ + + */ + protected function getPackageProviders($app): array + { + return [ + PhpSocketsServiceProvider::class, + ]; + } + + public function testStatusCommandRuns(): void + { + $this->artisan('phpsockets:status') + ->assertExitCode(0); + } + + public function testMemoryMigrateCommandDoesNothingSuccessfully(): void + { + config()->set('phpsockets.storage.driver', 'memory'); + + $this->artisan('phpsockets:migrate') + ->assertExitCode(0); + } + + public function testSqliteMigrateCommandCreatesDatabase(): void + { + if (!extension_loaded('pdo_sqlite')) { + self::markTestSkipped('pdo_sqlite extension is not available.'); + } + + $database = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpsockets_laravel_' . bin2hex(random_bytes(6)) . '.sqlite'; + + config()->set('phpsockets.storage.driver', 'sqlite'); + config()->set('phpsockets.storage.database', $database); + + $this->artisan('phpsockets:migrate', [ + '--driver' => 'sqlite', + '--database' => $database, + ])->assertExitCode(0); + + self::assertFileExists($database); + + @unlink($database); + } +} diff --git a/tests/Integration/Laravel/ServiceProviderTest.php b/tests/Integration/Laravel/ServiceProviderTest.php new file mode 100644 index 0000000..88a76c1 --- /dev/null +++ b/tests/Integration/Laravel/ServiceProviderTest.php @@ -0,0 +1,61 @@ + + */ + protected function getPackageProviders($app): array + { + return [ + PhpSocketsServiceProvider::class, + ]; + } + + /** + * @return array + */ + protected function getPackageAliases($app): array + { + return [ + 'PhpSockets' => PhpSocketsFacade::class, + ]; + } + + public function testItRegistersConfiguration(): void + { + self::assertSame('127.0.0.1', config('phpsockets.server.host')); + self::assertSame(8080, config('phpsockets.server.port')); + self::assertSame(2 * 1024 * 1024, config('phpsockets.chat.max_attachment_bytes')); + } + + public function testItRegistersManagerAndConfigs(): void + { + self::assertInstanceOf(PhpSocketsManager::class, $this->app->make(PhpSocketsManager::class)); + self::assertInstanceOf(ServerConfig::class, $this->app->make(ServerConfig::class)); + self::assertInstanceOf(ChatConfig::class, $this->app->make(ChatConfig::class)); + } + + public function testItRegistersChatServerBinding(): void + { + self::assertInstanceOf(ChatServer::class, $this->app->make(ChatServer::class)); + self::assertInstanceOf(ChatServer::class, $this->app->make('phpsockets')); + } + + public function testFacadeResolvesChatServer(): void + { + self::assertInstanceOf(ChatServer::class, PhpSocketsFacade::getFacadeRoot()); + } +}