Real-time chat applications have become increasingly popular in recent years, and implementing WebSocket servers to enable bidirectional communication has become essential for building such applications. However, creating this kind of can be a challenging task.
Fortunately, there are new frameworks and tools available that make it easier to set up WebSocket servers. In this tutorial, we will demonstrate how to create a real-time chat application using the Spiral Framework, RoadRunner, and Centrifugo with authentication and bidirectional communication.
Spiral Framework offers an array of seamlessly integrated components, which makes it an ideal choice for building complex applications. In this tutorial, we will guide you on creating a simple real-time chat application using Spiral Framework, Centrifugo, RoadRunner, and ORM.
Note
This tutorial covers the basics of the components and approaches used in creating the chat application. For more detailed information, we suggest referring to the relevant sections. Additionally, a demo repository is available at here.
To get started with building your real-time chat application, you can easily install the default spiral/app
bundle
with most of the required components by running the following command:
composer create-project spiral/app realtime-chat
During the installation process, you will be prompted to select various options with the Spiral installer, such as the application preset, whether to use Cycle ORM, which collections to use, which validator component to use, and so on. For this tutorial, we recommend choosing the options shown above:
✔ Which application preset do you want to install? > Web✔ Create a default application structure and demo data? > No✔ Would you like to use SAPI? > No✔ Do you need Cycle ORM? > Yes✔ Which Collections do you want to use with Cycle ORM? > Doctrine Collections✔ Which validator component do you want to use? > Spiral Validator✔ Do you want to use Queue component? > No✔ Do you want to use Cache component? > No✔ Do you want to use Mailer Component? > No✔ Do you want to use Storage component? > No✔ Which template engine do you want to use? > Stempler✔ Do you want to use the Event Dispatcher? > No✔ Do you need a cron jobs scheduler? > No✔ Do you need the Temporal? > No✔ Do you need the RoadRunner Metrics? > No✔ Do you need the Sentry? > No
Once the installation is complete, you can start the server and open your application immediately by running the following command:
cd realtime-chat./rr serve
Your application will be available on http://127.0.0.1:8080 by default.
Centrifugo is a powerful real-time messaging server. To install it, we have prepared a simple bash script that downloads
the latest version of the Centrifugo binary and installs it in the bin
directory of your application. You can run the
script using the following command:
wget --timeout=10 https://github.com/centrifugal/centrifugo/releases/download/v4.1.2/centrifugo_4.1.2_linux_amd64.tar.gz
mkdir -p bin
tar xvfz centrifugo_4.1.2_linux_amd64.tar.gz centrifugo
rm -rf centrifugo_4.1.2_linux_amd64.tar.gz
mv centrifugo bin/
chmod +x ./bin/centrifugo
After installing Centrifugo, the next step is to create a centrifugo.json
configuration file at the root directory of
your project, which will contain the necessary configuration details:
{
"allowed_origins": [
"*"
],
"proxy_connect": true,
"address": "127.0.0.1",
"port": 8081,
"grpc_api": true,
"grpc_api_address": "127.0.0.1",
"grpc_api_port": 10000,
"proxy_connect_endpoint": "grpc://127.0.0.1:10001",
"proxy_connect_timeout": "10s",
"proxy_rpc_endpoint": "grpc://127.0.0.1:10001",
"proxy_rpc_timeout": "10s"
}
To enable communication between Centrifugo and RoadRunner, you need to configure RoadRunner in the .rr.yaml
file by
specifying the details of the gRPC server and its connection to Centrifugo. This allows RoadRunner to handle Centrifugo
events and send them to your application, and vice versa.
#...
service:
# Create a new service that will run Centrifugo server
cetrifugo:
service_name_in_log: true
remain_after_exit: true
restart_sec: 1
command: "./bin/centrifugo --config=centrifugo.json"
centrifuge:
proxy_address: tcp://127.0.0.1:10001
grpc_api_address: tcp://127.0.0.1:10000
pool:
reset_timeout: 10
num_workers: 5
With these configurations in place, your application will be able to send events to Centrifugo, and Centrifugo will be able to send events to your application, enabling bidirectional communication between them.
The configuration of Spiral applications is accomplished through configuration files located in the app/config
directory. You can use the predefined values in these files, or you can obtain the values programmatically using the
env
and directory
functions.
The application dependencies are defined in the composer.json
file, and they are activated as bootloaders in the
app/src/Application/Kernel.php
file.
To optimize our application and make it more lightweight, we need to add the required bootloaders and remove some of the
default ones. Let's make some changes in the app/src/Application/Kernel.php
file:
// ...
// RoadRunner
RoadRunnerBridge\LoggerBootloader::class,
RoadRunnerBridge\HttpBootloader::class,
+ RoadRunnerBridge\CentrifugoBootloader::class,
// ...
// Security and validation
Framework\Security\EncrypterBootloader::class,
Framework\Security\FiltersBootloader::class,
- Framework\Security\GuardBootloader::class,
// ...
Framework\Http\CsrfBootloader::class,
- Framework\Http\PaginationBootloader::class,
// ...
// ORM
CycleBridge\SchemaBootloader::class,
CycleBridge\CycleOrmBootloader::class,
CycleBridge\AnnotatedBootloader::class,
+ CycleBridge\AuthTokensBootloader::class,
// Views and view translation
ViewsBootloader::class,
- TranslatedCacheBootloader::class,
// ...
// Fast code prototyping
PrototypeBootloader::class,
+ CycleBridge\PrototypeBootloader::class,
Note
Read more about Bootloaders here.
By default, the routing rules are located in app/src/Application/Bootloader/RoutesBootloader.php
. You have many
options on how to configure the routing. Point route to actions, controllers, controller groups, set the default
pattern parameters, verbs, middleware, etc.
Remove method defineRoutes
. We will add routes using attributes:
class RoutesBootloader extends BaseRoutesBootloader
{
...
- protected function defineRoutes(RoutingConfigurator $routes): void
- {
- ...
- }
}
See more
Read more about routing in the HTTP — Routing section.
Since we won't be using the REST API in this example, let's remove the api
middleware group and add the
Spiral\Filter\ValidationHandlerMiddleware
for handling validation errors. In
the app/src/Application/Bootloader/RoutesBootloader.php
file, update the middlewareGroups
method as follows:
class RoutesBootloader extends BaseRoutesBootloader
{
...
protected function middlewareGroups(): array
{
return [
'web' => [
...
+ \Spiral\Filter\ValidationHandlerMiddleware::class,
+ \Spiral\Auth\Middleware\AuthMiddleware::class,
],
- 'api' => [
- ...
- ],
];
}
}
Note
As a reminder, when making changes to the code, it's important to remove any unused imports. These can clutter up the code and make it harder to read and maintain.
To enable broadcasting functionality in the application, it is necessary to configure the broadcasting.php
configuration file. This configuration will allow the application to transmit events to the Centrifugo server.
return [
'connections' => [
'centrifugo' => [
'driver' => 'centrifugo',
],
],
];
Additionally, it is necessary to set the BROADCAST_CONNECTION
environment variable to the newly created connection:
# Broadcast
BROADCAST_CONNECTION=centrifugo
Following these steps will enable the application to broadcast events to the Centrifugo server.
Note
Thecentrifugo
driver is provided by thespiral/roadrunner-bridge
package.
In order for our application to function properly, a database connection must be established. The database configuration
file can be found in the app/config/database.php
file. For this particular application, we will be utilizing a
PostgreSQL database.
use Cycle\Database\Config;
return [
'logger' => [
'default' => env('DB_LOGGER_DRIVER'),
'drivers' => [
// 'runtime' => 'stdout'
],
],
+ 'default' => env('DB_CONNECTION', 'default'),
'databases' => [
'default' => [
'driver' => 'runtime',
],
],
'drivers' => [
- 'runtime' => new Config\SQLiteDriverConfig(
- connection: new Config\SQLite\FileConnectionConfig(
- database: directory('runtime') . '/db.sqlite'
- ),
- queryCache: true
- ),
+ 'runtime' => new Config\PostgresDriverConfig(
+ connection: new Config\Postgres\TcpConnectionConfig(
+ database: env('DB_DATABASE', 'homestead'),
+ user: env('DB_USERNAME', 'homestead'),
+ password: env('DB_PASSWORD', 'secret'),
+ port: (int) env('DB_PORT', 5432),
+ ),
+ schema: 'public',
+ queryCache: true,
+ options: [
+ 'withDatetimeMicroseconds' => true,
+ 'logQueryParameters' => env('DB_LOG_QUERY_PARAMETERS', false),
+ ],
+ ),
],
];
We can store a database name, username, password and port in .env
file, add the following lines into it:
DB_HOST=localhost
DB_NAME=homestead
DB_USER=homestead
DB_PASSWORD=secret
DB_PORT=5432
To verify that the database connection has been successfully established, the following command should be executed:
php app.php db:list
See more
For further information regarding database configuration, please refer to the database configuration documentation.
We will need some sample data for the application. Let's install Database Seeder.
To activate the package, the Spiral\DatabaseSeeder\Bootloader\DatabaseSeederBootloader
bootloader must be added to
the LOAD
section:
PrototypeBootloader::class,
+ \Spiral\DatabaseSeeder\Bootloader\DatabaseSeederBootloader::class,
Once these steps have been completed, the Database Seeder package will be enabled and can be utilized to provide sample data for the application.
The framework provides the ability to configure the database schema using a collection of migration files. To initiate the migration configuration process within your application, execute the following command:
php app.php migrate:init
After executing the previous command, you can observe the structure of the migration table by utilizing the following commands:
php app.php db:listphp app.php db:table migrations
Once the migration configuration has been initialized, you may manually write the migration files or allow Cycle ORM to generate them for you.
Let's create Thread
, Message
and User
entities and their repositories using
the scaffolder component:
php app.php create:entity thread -f id:primary -f name:string -ephp app.php create:entity message -f id:primary -f message:string -ephp app.php create:entity user -f id:primary -f username:string -f password:string -e
Note
Upon successful execution of the previous commands, the generated classes may be located within theapp/src/Database
andapp/src/Repository
directories.
After the Thread
entity has been created, it looks like this:
namespace App\Database;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
#[Entity(repository: '\App\Repository\ThreadRepository')]
class Thread
{
#[Column(type: 'primary')]
public int $id;
#[Column(type: 'string')]
public string $name;
}
Let's bring it in order:
namespace App\Database;
use App\Repository\ThreadRepository;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
#[Entity(repository: ThreadRepository::class)]
class Thread implements \JsonSerializable
{
#[Column(type: 'primary')]
private int $id;
public function __construct(
#[Column(type: "string")]
private string $name,
) {
}
public function getName(): string
{
return $this->name;
}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
}
The generated Message
and User
entities and its repositories may be similarly modified to ensure that their
properties and functionality aligns with the application requirements.
namespace App\Database;
use App\Repository\MessageRepository;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Relation\BelongsTo;
#[Entity(repository: MessageRepository::class)]
class Message implements \JsonSerializable
{
#[Column(type: 'primary')]
private int $id;
public function __construct(
#[BelongsTo(target: Thread::class)]
private Thread $thread,
#[BelongsTo(target: User::class)]
private User $user,
#[Column(type: "text")]
private string $text,
) {
}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'user' => $this->user,
'text' => $this->text,
];
}
}
namespace App\Repository;
use App\Database\Message;
use Cycle\ORM\Select\Repository;
final class MessageRepository extends Repository
{
/**
* @return Message[]
*/
public function findAllByThread(int $threadId): array
{
return $this->findAll([
'thread_id' => $threadId,
], [
'id' => 'ASC',
]);
}
}
namespace App\Database;
use App\Repository\UserRepository;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Table\Index;
#[Entity(repository: UserRepository::class)]
#[Index(columns: ['username'], unique: true)]
class User implements \JsonSerializable
{
#[Column(type: 'primary')]
private int $id;
public function __construct(
#[Column(type: "string")]
private string $username,
#[Column(type: "string")]
private string $password,
) {
}
public function getId(): int
{
return $this->id;
}
public function getPassword(): string
{
return $this->password;
}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'username' => $this->username,
];
}
}
namespace App\Repository;
use App\Database\User;
use Cycle\ORM\Select\Repository;
final class UserRepository extends Repository
{
public function findByUsername(string $username): ?User
{
return $this->findOne(['username' => $username]);
}
}
Note
Read more about Cycle here.
Run the configure command to collect all available prototype classes:
php app.php configure
To generate the database schema, use the following command:
php app.php cycle:migrate -v
The generated migration can be found in the app/migrations/
directory. You can execute the migration using the
following command:
php app.php migrate -vv
You can use the db:list
command to observe the generated tables.
php app.php db:list
To generate test data, we need factories that describe the rules for generating an entity and seeders that fill the
database. To maintain separation from the application code, these factories and seeders should be stored in a separate
folder named app/database
.
Let's add a separate Database
namespace to Composer autoload, you can update the composer.json
file as follows:
--- a/composer.json
+++ b/composer.json
"autoload-dev": {
"psr-4": {
"Tests\\": "tests",
+ "Database\\": "app/database"
},
//
},
After updating the composer.json
file, run the following command to update the autoloader:
composer dump-autoload
The next step is to create the UserFactory
class, which will be responsible for generating user entities. To achieve
this, extend the AbstractFactory
class provided by Spiral\DatabaseSeeder\Factory
, and implement the required
methods.
To create it, run the following command:
php app.php create:factory UserFactory
Once it's created, modify the contents of the app/database/Factory/UserFactory.php
file to look like the following:
namespace Database\Factory;
use App\Database\User;
use Spiral\DatabaseSeeder\Factory\AbstractFactory;
final class UserFactory extends AbstractFactory
{
public function entity(): string
{
return User::class;
}
public function makeEntity(array $definition): User
{
return new User(
username: $definition['username'],
password: $definition['password'],
);
}
public function definition(): array
{
return [
'username' => $this->faker->userName(),
'password' => \password_hash('secret', \PASSWORD_BCRYPT),
];
}
}
To generate test data for threads, create a ThreadFactory
class.
To create it, run the following command:
php app.php create:factory ThreadFactory
Once it's created, modify the contents of the app/database/Factory/ThreadFactory.php
file to look like the following:
namespace Database\Factory;
use App\Database\Thread;
use Spiral\DatabaseSeeder\Factory\AbstractFactory;
final class ThreadFactory extends AbstractFactory
{
public function entity(): string
{
return Thread::class;
}
public function makeEntity(array $definition): Thread
{
return new Thread(
name: $definition['name']
);
}
public function definition(): array
{
return [
'name' => $this->faker->sentence,
];
}
}
To generate test data for messages, create a MessageFactory
class.
To create it, run the following command:
php app.php create:factory MessageFactory
Once it's created, modify the contents of the app/database/Factory/MessageFactory.php
file to look like the following:
namespace Database\Factory;
use App\Database\Message;
use Spiral\DatabaseSeeder\Factory\AbstractFactory;
final class MessageFactory extends AbstractFactory
{
public function makeEntity(array $definition): object
{
return new Message(
$definition['thread'],
$definition['user'],
$definition['text'],
);
}
public function entity(): string
{
return Message::class;
}
public function definition(): array
{
return [
'thread' => ThreadFactory::new()->make(),
'user' => UserFactory::new()->make(),
'text' => $this->faker->paragraph,
];
}
}
To fill the database with test data, create seeders that use the previously created factories.
To create a UserTableSeeder
class, run the following command:
php app.php create:seeder UserTableSeeder
Once the UserTableSeeder
class is created, modify the contents of the app/database/Seeder/UserTableSeeder.php
file
to look like the following:
namespace Database\Seeder;
use Database\Factory\UserFactory;
use Spiral\DatabaseSeeder\Seeder\AbstractSeeder;
final class UserTableSeeder extends AbstractSeeder
{
public function run(): \Generator
{
yield UserFactory::new(['username' => 'john'])->makeOne();
yield UserFactory::new(['username' => 'bill'])->makeOne();
}
}
Note
We will create only 2 users:john
andbill
, if you need more, you can do it in the same way.
To create a ThreadTableSeeder
class, run the following command:
php app.php create:seeder ThreadTableSeeder
Once the ThreadTableSeeder
class is created, modify the contents of the app/database/Seeder/ThreadTableSeeder.php
file to look like the following:
namespace Database\Seeder;
use Database\Factory\ThreadFactory;
use Spiral\DatabaseSeeder\Seeder\AbstractSeeder;
final class ThreadTableSeeder extends AbstractSeeder
{
public function run(): \Generator
{
yield ThreadFactory::new(['name' => 'First thread'])->makeOne();
}
}
Note
We will create only 1 thread. It's enough for our example.
Now let's execute a console command that will populate the database with test records:
php app.php db:seed
At first, we need to create a controller that will authenticate users in our chat.
Let's create it using scaffolder:
php app.php create:controller login -a loginForm -a login -p
Note
Use option-a
to pre-generate controller actions and option-p
to pre-load prototype extension.
The generated code:
namespace App\Endpoint\Web;
use Psr\Http\Message\ResponseInterface;
use Spiral\Prototype\Traits\PrototypeTrait;
use Spiral\Router\Annotation\Route;
class LoginController
{
use PrototypeTrait;
#[Route(route: 'path', name: 'name')]
public function loginForm(): ResponseInterface
{
}
#[Route(route: 'path', name: 'name')]
public function login(): ResponseInterface
{
}
}
To render the login form, we will use the Stempler
templating engine.
Here is a code for the login form.
use Psr\Http\Message\ServerRequestInterface;
final class LoginController
{
// ...
#[Route('/login', methods: ['GET'])]
public function loginForm(ServerRequestInterface $request): ResponseInterface|string
{
return $this->views->render('login', [
'csrf' => $request->getAttribute('csrfToken'),
'errors' => [],
]);
}
}
We use csrf token to protect the login form from CSRF attacks. The token is generated by
the Spiral\Csrf\Middleware\CsrfMiddleware
middleware and stored in request attributes.
Let's create a view template app/views/login.dark.php
for the login form:
<html>
<head>
<title>Login</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="min-h-screen bg-gray-100 flex flex-col justify-center sm:py-12">
<div class="p-10 xs:p-0 mx-auto md:w-full md:max-w-md">
<h1 class="font-bold text-center text-2xl mb-5">Your Logo</h1>
<form action="/login" method="POST" class="bg-white shadow w-full rounded-lg divide-y divide-gray-200">
<input type="hidden" name="csrf-token" value="{{ $csrf }}"/>
@foreach ($errors ?? [] as $error)
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<strong class="font-bold">Error!</strong>
<span class="block sm:inline">{{ $error }}</span>
</div>
@endforeach
<div class="px-5 py-7">
<label class="font-semibold text-sm text-gray-600 pb-1 block">Username</label>
<input type="text" class="border rounded-lg px-3 py-2 mt-1 mb-5 text-sm w-full" name="username"/>
<label class="font-semibold text-sm text-gray-600 pb-1 block">Password</label>
<input type="password" name="password" class="border rounded-lg px-3 py-2 mt-1 mb-5 text-sm w-full"/>
<button type="submit"
class="bg-blue-500 hover:bg-blue-600 text-white w-full py-2.5 rounded-lg text-sm text-center inline-block">
<span class="inline-block mr-2">Login</span>
</button>
</div>
</form>
</div>
</div>
</body>
</html>
Now let's create a login handler. It will authenticate users and redirect them to the chat page.
We will store authentication tokens using Cycle ORM, so we need to set AUTH_TOKEN_STORAGE
environment variable
to cycle
:
AUTH_TOKEN_STORAGE=cycle
Now we need to create a request filter that will validate the login form data.
namespace App\Entrypoint\Web\Filter;
use Spiral\Filters\Attribute\Input\Post;
use Spiral\Filters\Model\Filter;
use Spiral\Filters\Model\FilterDefinitionInterface;
use Spiral\Filters\Model\HasFilterDefinition;
use Spiral\Validator\FilterDefinition;
final class LoginRequest extends Filter implements HasFilterDefinition
{
#[Post]
public string $username;
#[Post]
public string $password;
public function filterDefinition(): FilterDefinitionInterface
{
return new FilterDefinition([
'username' => ['notEmpty', 'string'],
'password' => ['notEmpty', 'string'],
]);
}
}
and InvalidCredentialsException
exception that will be thrown if the user credentials are invalid:
namespace App\Application\Exception;
final class InvalidCredentialsException extends \Exception
{
}
Now we can implement the login action:
use App\Application\Exception\InvalidCredentialsException;
use App\Entrypoint\Web\Filter\LoginRequest;
final class LoginController
{
// ...
#[Route('/login', methods: ['POST'])]
public function login(LoginRequest $filter): ResponseInterface
{
$user = $this->users->findByUsername($filter->username);
if (!$user || !\password_verify($filter->password, $user->getPassword())) {
throw new InvalidCredentialsException('Invalid username or password!');
}
$token = $this->authTokens->create($user->jsonSerialize());
$this->auth->start($token);
return $this->response->redirect('/');
}
}
Note
It would be a good idea to verify the user password in a special service. But for our example, it's enough.
After the user is authenticated, we create a new authentication token with a payload that contains the user data.
[
'id' => ...,
'username' => ...,
]
Note
You can store any data in the token payload. Basically, a token should contain all the data that you need to identify the user. In most cases,id
orusername
is enough.
Now we need to handle the InvalidCredentialsException
exception and display an error message on the login form.
In our application we will store errors in the session.
Warning
Session is not the best place to store errors for sharing between requests. But for our example, it's enough.
Let's create a new service that will store errors in the session:
namespace App\Entrypoint\Web;
use Spiral\Prototype\Annotation\Prototyped;
use Spiral\Prototype\Traits\PrototypeTrait;
use Spiral\Session\SessionSectionInterface;
#[Prototyped(property: 'errors')]
final class SessionErrors
{
use PrototypeTrait;
public function clear(): void
{
$this->session()->clear();
}
/**
* @return array<non-empty-string, non-empty-string[]>
*/
public function getErrors(): array
{
$errors = $this->session()->getAll();
// clear errors after reading
$this->clear();
return $errors;
}
/**
* @param non-empty-string $key
* @param non-empty-string $error
*/
public function addError(string $key, string $error): void
{
$this->session()->set($key, $error);
}
private function session(): SessionSectionInterface
{
return $this->session->getSection('errors');
}
}
And run the following command to register the service as a prototype:
php app.php prototype:dump
Ok, now we can use our service as a prototype using Spiral\Prototype\Traits\PrototypeTrait
with the property
name errors
.
Let's create a new middleware that will handle the exception:
namespace App\Entrypoint\Web\Middleware;
use App\Application\Exception\InvalidCredentialsException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Spiral\Prototype\Traits\PrototypeTrait;
final class HandleInvalidCredentialsMiddleware implements MiddlewareInterface
{
use PrototypeTrait;
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$response = $handler->handle($request);
// Flush errors after successful request
$this->errors->clear();
return $response;
} catch (InvalidCredentialsException $e) {
// Add error to the session and redirect to the login form
$this->errors->addError('username', $e->getMessage());
return $this->response->redirect('/login');
}
}
}
And register the middleware in the RoutesBootloader
:
final class RoutesBootloader extends BaseRoutesBootloader
{
// ...
protected function middlewareGroups(): array
{
return [
'web' => [
// ...
+ \App\Entrypoint\Web\Middleware\HandleInvalidCredentialsMiddleware::class,
],
];
}
}
To display errors on the login form we need to get them from the SessionErrors
service and pass to the view:
final class LoginController
{
// ...
#[Route('/login', methods: ['GET'])]
public function loginForm(ServerRequestInterface $request): ResponseInterface|string
{
return $this->views->render('login', [
'csrf' => $request->getAttribute('csrfToken'),
+ 'errors' => $this->errors->getErrors(),
]);
}
}
To access the chat page, the user must be authenticated. So we need to create a middleware group for authenticated users:
+use Spiral\Core\Container\Autowire;
+use Spiral\Auth\Middleware\Firewall\RedirectFirewall;
final class RoutesBootloader extends BaseRoutesBootloader
{
// ...
protected function middlewareGroups(): array
{
return [
'web' => [
// ...
],
+ 'auth' => [
+ 'middleware:web',
+ new Autowire(RedirectFirewall::class, ['uri' => new \Nyholm\Psr7\Uri('/login')]),
+ ],
];
}
}
Note
A middleware group can extend another group. In our case, theauth
group extends theweb
group. All the registered groups name will have themiddleware:
prefix.
Now let's create a chat controller:
php app.php create:controller chat -a index -p
Here is final code of the controller:
namespace App\Endpoint\Web;
use Spiral\Prototype\Traits\PrototypeTrait;
use Spiral\Router\Annotation\Route;
final class ChatController
{
use PrototypeTrait;
#[Route('/', group: 'auth')]
public function index(): string
{
return $this->views->render('chat', [
'token' => $this->auth->getToken()->getID(),
]);
}
}
Note
As you can see, we use theauth
middleware group inRoute
attribute to protect the chat page.
Now let's create a view for the chat page:
<html>
<head>
<title>Chat</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<meta name="x-bearer" content="{{ $token }}">
</head>
<body>
<div id="app" class="w-full h-screen"></div>
<script type="module" src="/frontend.js"></script>
</body>
</html>
As you noticed, we use the x-bearer
meta tag to pass the authentication token to the frontend application. This
token will be used to authenticate the user in the Centrifugo server.
Spiral Framework provides a way to handle events from the Centrifugo server. In our example, we will handle two types of events:
connect
- when a user connects to the Centrifugo server.rpc
- when a user sends a message from the chat to the Centrifugo server.When a user connects to the Centrifugo server it also sends an authentication token. We use this token to authenticate identity of the user. If the token is valid, we return the user's ID and channels to which the user should be automatically subscribed after connecting to the server, otherwise, user will be disconnected from the server.
To find a user by the token, we need to create a user provider that implements
the Spiral\Auth\ActorProviderInterface
:
namespace App\Endpoint\Centrifugo;
use Spiral\Auth\ActorProviderInterface;
use Spiral\Auth\TokenInterface;
use Spiral\Prototype\Traits\PrototypeTrait;
final class UserProvider implements ActorProviderInterface
{
use PrototypeTrait;
public function getActor(TokenInterface $token): ?object
{
if (!isset($token->getPayload()['id'])) {
return null;
}
return $this->users->findByPK($token->getPayload()['id']);
}
}
And register it in the AuthBootloader
:
php app.php create:bootloader AuthBootloader
namespace App\Application\Bootloader;
use App\Endpoint\Centrifugo\UserProvider;
use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Bootloader\Auth\AuthBootloader as BaseAuthBootloader;
final class AuthBootloader extends Bootloader
{
public function init(BaseAuthBootloader $auth): void
{
$auth->addActorProvider(UserProvider::class);
}
}
To activate the bootloader we need to register it in the Kernel
:
// ORM
CycleBridge\AuthTokensBootloader::class,
+\App\Application\Bootloader\AuthBootloader::class,
That's it. Now we can get the authenticated user (Actor
) by a token from the ActorProviderInterface
.
To handle the connect
event, we need to create a ConnectHandler
class. Let's create it:
php app.php create:centrifugo-handler Connect
And modify the generated code:
namespace App\Endpoint\Centrifugo;
use App\Database\User;
use RoadRunner\Centrifugo\Payload\ConnectResponse;
use RoadRunner\Centrifugo\Request\Connect;
use RoadRunner\Centrifugo\Request\RequestInterface;
use Spiral\Auth\ActorProviderInterface;
use Spiral\Prototype\Traits\PrototypeTrait;
use Spiral\RoadRunnerBridge\Centrifugo\ServiceInterface;
final class ConnectHandler implements ServiceInterface
{
use PrototypeTrait;
public function __construct(
private readonly ActorProviderInterface $actorProvider,
) {
}
/** @param Connect $request */
public function handle(RequestInterface $request): void
{
try {
// Authenticate user with a given token from the connection request
$authToken = $request->getData()['authToken'] ?? null;
if ($authToken && $user = $this->getActor($authToken)) {
$userId = $user->getId();
} else {
$request->error(403, 'Forbidden');
return;
}
$request->respond(
new ConnectResponse(
user: (string)$userId,
data: ['user' => $user->jsonSerialize()],
channels: ['chat'],
),
);
} catch (\Throwable $e) {
$request->error($e->getCode(), $e->getMessage());
}
}
public function getActor(?string $authToken): ?User
{
if ($authToken && $token = $this->authTokens->load($authToken)) {
return $this->actorProvider->getActor($token);
}
return null;
}
}
As you can notice in the example above, we use channels
field to automatically subscribe the user to the
desired channels after connecting to the server. And we don't need to subscribe the user to the channel manually
on the client side.
Note
When we respond to the request, we also pass the user's data to thedata
field to be able to use it in the frontend application.
Now we need to register the ConnectHandler
as a service that should handle the connect
event:
use App\Endpoint\Centrifugo\ConnectHandler;
use RoadRunner\Centrifugo\Request\RequestType;
return [
'services' => [
RequestType::Connect->value => ConnectHandler::class,
],
];
Now we will be able to handle the connect
event.
RPC event is used to send messages from the client to the server. In our example, we will use this event to send list of available messages in a given thread and to store a new message in the database when a user sends a message from the chat.
To handle the rpc
event, we need to create a RpcHandler
class. Let's create it:
php app.php create:centrifugo-handler Rpc -t=rpc
namespace App\Endpoint\Centrifugo;
use App\Database\Message;
use App\Database\User;
use RoadRunner\Centrifugo\Payload\RPCResponse;
use RoadRunner\Centrifugo\Request\RequestInterface;
use RoadRunner\Centrifugo\Request\RPC;
use Spiral\Core\InvokerInterface;
use Spiral\Prototype\Traits\PrototypeTrait;
use Spiral\RoadRunnerBridge\Centrifugo\ServiceInterface;
final class RpcHandler implements ServiceInterface
{
use PrototypeTrait;
public function __construct(
private readonly InvokerInterface $invoker,
) {
}
/**
* @param RPC $request
*/
public function handle(RequestInterface $request): void
{
// Invoke a method based on the request method
$result = match ($request->method) {
// Return a list of messages in a given thread
'thread:history' => $this->invoker->invoke(
[$this, 'threadHistory'],
$request->getData(),
),
// Store a new message in the database
'thread:publish' => $this->invoker->invoke(
[$this, 'threadNewMessage'],
$request->getData(),
),
// Return an error if the method is not found
default => ['error' => 'Not found', 'code' => 404]
};
try {
$request->respond(new RPCResponse(data: $result));
} catch (\Throwable $e) {
$request->error($e->getCode(), $e->getMessage());
}
}
private function threadNewMessage(
int $id,
string $message,
): array {
$thread = $this->threads->findByPK($id);
/** @var User $user */
$user = $this->auth->getActor();
$message = new Message($thread, $user, $message);
$this->entityManager->persist($message)->run();
$this->broadcast->publish(
'chat',
\json_encode([
'type' => 'message',
'message' => $message,
'thread' => $thread,
]),
);
return ['message' => $message, 'thread' => $thread];
}
private function threadHistory(int $id): array
{
return ['messages' => $this->messages->findAllByThread($id)];
}
}
All the requests to the rpc
event will contain authentication token in the authToken
field. Let's try to use
another approach to authenticate the user. We will use an interceptor to authenticate the user. Use interceptors is a
good practice because it allows you to reuse the same authentication logic for different events.
Let's create the interceptor that will authenticate the user based on the authToken
field:
namespace App\Endpoint\Centrifugo\Interceptor;
use Psr\EventDispatcher\EventDispatcherInterface;
use RoadRunner\Centrifugo\Request\RequestInterface;
use Spiral\Auth\ActorProviderInterface;
use Spiral\Auth\AuthContext;
use Spiral\Auth\AuthContextInterface;
use Spiral\Core\CoreInterceptorInterface;
use Spiral\Core\CoreInterface;
use Spiral\Core\ScopeInterface;
use Spiral\Prototype\Traits\PrototypeTrait;
final class AuthInterceptor implements CoreInterceptorInterface
{
use PrototypeTrait;
public function __construct(
private readonly ScopeInterface $scope,
private readonly ActorProviderInterface $actorProvider,
private readonly ?EventDispatcherInterface $eventDispatcher = null,
) {
}
public function process(string $controller, string $action, array $parameters, CoreInterface $core): mixed
{
$request = $parameters['request'];
\assert($request instanceof RequestInterface);
$authToken = $request->getData()['authToken'] ?? null;
if (!$authToken || !$token = $this->authTokens->load($authToken)) {
$request->error(403, 'Unauthorized');
return null;
}
$auth = new AuthContext($this->actorProvider, $this->eventDispatcher);
$auth->start($token);
if ($auth->getActor() === null) {
$request->error(403, 'Unauthorized');
return null;
}
return $this->scope->runScope([
AuthContextInterface::class => $auth,
], fn () => $core->callAction($controller, $action, $parameters));
}
}
As we found out earlier, interceptors can be reused in different events. So we can use it not only for the rpc
event
but also for the connect
event.
Let's register the AuthInterceptor
and the RpcHandler
in the centrifugo.php
config file:
+use App\Endpoint\Centrifugo\Interceptor\AuthInterceptor;
use App\Endpoint\Centrifugo\ConnectHandler;
+use App\Endpoint\Centrifugo\RpcHandler;
use RoadRunner\Centrifugo\Request\RequestType;
return [
'services' => [
RequestType::Connect->value => ConnectHandler::class,
+ RequestType::RPC->value => RpcHandler::class,
],
+ 'interceptors' => [
+ RequestType::RPC->value => [
+ AuthInterceptor::class,
+ ],
+ RequestType::Connect->value => [
+ AuthInterceptor::class,
+ ],
+ ],
];
Note
You can also use key*
as wildcard to register the same interceptor for multiple events.
And modify the ConnectHandler
to make it cleaner:
namespace App\Endpoint\Centrifugo;
use App\Entity\User;
use RoadRunner\Centrifugo\Payload\ConnectResponse;
use RoadRunner\Centrifugo\Request\Connect;
use RoadRunner\Centrifugo\Request\RequestInterface;
use Spiral\Auth\ActorProviderInterface;
use Spiral\Prototype\Traits\PrototypeTrait;
use Spiral\RoadRunnerBridge\Centrifugo\ServiceInterface;
final class ConnectHandler implements ServiceInterface
{
use PrototypeTrait;
- public function __construct(
- private readonly ActorProviderInterface $actorProvider,
- ) {
- }
/** @param Connect $request */
public function handle(RequestInterface $request): void
{
try {
- // Authenticate user with a given token from the connection request
- $authToken = $request->getData()['authToken'] ?? null;
- if ($authToken && $user = $this->getActor($authToken)) {
- $userId = $user->getId();
- } else {
- $request->error(403, 'Forbidden');
- return;
- }
+ $user = $this->auth->getActor();
$request->respond(
new ConnectResponse(
+ user: (string)$user->getId(),
data: ['user' => $user->jsonSerialize()],
channels: ['chat'],
),
);
} catch (\Throwable $e) {
$request->error($e->getCode(), $e->getMessage());
}
}
- public function getActor(?string $authToken): ?User
- {
- ...
- }
}
Note
Using the same approach, you can also use interceptors to handle events exceptions.
We will use Vue.js to create a chat application. So let's create a new one.
npm init vue@latest
You can see the answers that I used in the example below:
✔ Project name: … frontend✔ Add TypeScript? … No✔ Add JSX Support? … No✔ Add Vue Router for Single Page Application development? … No✔ Add Pinia for state management? … No✔ Add Vitest for Unit testing? … No✔ Add Cypress for both Unit and End-to-End testing? … No✔ Add ESLint for code quality? … No✔ Add Prettier for code formatting? … NoScaffolding project in ./frontend...Done.
See more
You can find out more information about the Vue.js in the Official documentation.
We need also to install the centrifugo
package from npm:
cd frontendnpm install centrifuge -s
Let's open vite.config.js
and modify it:
import {fileURLToPath, URL} from 'node:url';
import {resolve} from 'path';
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue()
],
build: {
lib: {
entry: resolve(__dirname, 'src/main.js'),
name: 'Chat',
formats: ['cjs']
},
outDir: '../public'
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
define: {
'process.env': {}
}
})
And delete frontend/public
and frontend/src/
folders. We will create everything from scratch.
Ok, now we are ready to create a chat application.
Let's create frontend/src/main.js
file:
import {createApp} from 'vue'
import App from './App.vue'
import {Centrifuge} from "centrifuge";
const app = createApp(App)
app.use({
install(app, options) {
// Get the auth token from x-bearer meta tag
const authToken = document.getElementsByName('x-bearer')[0].getAttribute('content')
// Register the auth token as a global property
app.config.globalProperties.authToken = authToken
// Create a new Centrifuge instance
const centrifuge = new Centrifuge('ws://127.0.0.1:8081/connection/websocket', {
data: {authToken}
});
// Store the user data in the global property when the client is connected
centrifuge.on('connected', (ctx) => {
app.config.globalProperties.user = ctx.data.user
})
centrifuge.connect();
// Register the centrifuge instance as a global property
app.config.globalProperties.centrifuge = centrifuge
}
})
app.mount('#app')
When a client is connected to the Centrifugo server, it will receive an authenticated user data from the
ConnectHandler
that we created earlier. We will register it as a user
global property to use it in our components.
In this file we will connect to the Centrifugo server and register centrifuge
as a global property to use it in our
components.
As you can see we use authToken
from the meta
tag. It will be used to authenticate the user on the server side via
Centrifugo.
Let's create a Message
component:
<template>
<div class="chat-message">
<div class="flex items-end" :class="{'justify-end': isOwnMessage}">
<div class="flex flex-col space-y-2 text-xs mx-2 order-2 items-start">
<div>
<span class="px-4 py-2 rounded-lg inline-block rounded-bl-none"
:class="{'bg-blue-600 text-white': isOwnMessage, 'bg-gray-300 text-gray-600': !isOwnMessage}">
{{ message.text }}
</span>
</div>
<span>
{{ message.user.username }}
</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
message: Object
},
computed: {
isOwnMessage() {
return this.message.user.id === this.user.id
}
}
}
</script>
We use isOwnMessage
computed property to determine if the message is sent by the current user or not. If it is sent by
the current user, we will display it on the right side of the chat.
The MessageForm
component will be used to send messages to the server. Let's create it:
<template>
<div class="border-t-2 border-gray-200 px-4 pt-4 mb-2 sm:mb-0">
<div class="relative flex">
<input v-model="message"
type="text"
@keyup.enter="sendMessage"
placeholder="Write your message!"
class="w-full focus:outline-none focus:placeholder-gray-400 text-gray-600 placeholder-gray-600 pl-6 bg-gray-200 rounded-md py-3">
<div class="absolute right-0 items-center inset-y-0 hidden sm:flex" v-if="message.length > 1">
<button type="button"
@click="sendMessage"
class="inline-flex items-center justify-center rounded-lg px-4 py-3 transition duration-500 ease-in-out text-white bg-blue-500 hover:bg-blue-400 focus:outline-none">
<span class="font-bold">Send</span>
</button>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
thread: Number
},
data() {
return {
message: ''
}
},
methods: {
sendMessage() {
this.centrifuge.rpc('thread:publish', {
id: this.thread,
message: this.message,
authToken: this.authToken
}).then(() => {
this.message = ''
})
}
}
}
</script>
The component will send a message to the server
via centrifuge.rpc with the thread:publish
method. As we
need to pass the authToken
to authenticate the user on the server side.
The Messages
component will be used to display all messages from the thread:
<template>
<div id="messages"
ref="messages"
class="flex flex-col space-y-4 p-3 overflow-y-auto scrollbar-thumb-blue scrollbar-thumb-rounded scrollbar-track-blue-lighter scrollbar-w-2 scrolling-touch">
<Message v-for="message in messages" :key="message.id" :message="message"/>
</div>
<MessageForm :thread="thread"/>
</template>
<script>
import MessageForm from "./MessageForm.vue";
import Message from "./Message.vue"
function delay(time) {
return new Promise(resolve => setTimeout(resolve, time));
}
export default {
components: {
Message, MessageForm
},
props: {
thread: Number
},
mounted() {
// Get all messages for the thread from the server
this.centrifuge.rpc('thread:history', {
id: this.thread,
authToken: this.authToken
}).then(ctx => {
this.messages = ctx.data.messages;
delay(10).then(() => this.scrollToBottom());
})
// Listen for new messages from the server
this.centrifuge.on('publication', ctx => {
if (
ctx.channel !== 'chat' ||
ctx.data.type !== 'message' ||
ctx.data.thread.id !== this.thread
) {
return;
}
this.messages.push(ctx.data.message);
delay(10).then(() => this.scrollToBottom());
})
},
methods: {
scrollToBottom() {
this.$refs.messages.scrollTop = this.$refs.messages.scrollHeight;
}
},
data() {
return {
messages: []
}
},
}
</script>
To listen for new messages we will use the publication
event of the centrifuge
object. This event will be triggered
every time a new message is published to the chat
channel. We check if the message is from the current thread and if
yes we add it to the messages
array.
To display the history of the thread we will use the thread:history
method of the centrifuge.rpc
method. It will
return the all the messages from the thread.
And the last thing is to scroll to the bottom of the messages list when a new message is received. But whe have to do it with a delay to give Vue time to update the DOM.
The App
component is the root component of our frontend application.
<script setup>
import Messages from "./components/Messages.vue";
</script>
<template>
<div class="flex-1 p:2 sm:p-6 justify-between flex flex-col h-screen">
<Messages :thread="1"/>
</div>
</template>
It's super simple. It just renders the Messages
component.
Note
For our example we will use only one thread with id1
. We won't implement the ability to create new threads or switch between them. But you can easily do it by yourself.
Now we can build the frontend:
cd ./frontendnpm run build
After the build is finished we will have the frontend.js
file in the /public
folder in the root of the project.
Ok, now we have the backend and the frontend. Let's run the application:
./rr serve
Open the application in your browser: http://127.0.0.1:8080 and use bill
or john
as a username and secret
as a
password to login. After authentication, you will be redirected to the chat page where you can send messages to the
server.
Spiral provides a lot of pre-build functionality for you. Read the following sections to gain more insights: