feat: add queue and horizon

This commit is contained in:
Leon Morival 2026-04-29 12:52:16 +02:00
parent fba0942407
commit 2894b59452
18 changed files with 794 additions and 25 deletions

View File

@ -35,7 +35,7 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
QUEUE_CONNECTION=redis
CACHE_STORE=database
# CACHE_PREFIX=
@ -49,9 +49,13 @@ MEILISEARCH_PORT=7700
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_QUEUE=default
HORIZON_NAME="${APP_NAME}"
HORIZON_PREFIX=treasure_api_horizon:
MAIL_MAILER=log
MAIL_SCHEME=null

View File

@ -63,6 +63,8 @@ deploy-to-portainer:
export MAIL_MAILER="${MAIL_MAILER:-smtp}"
export MAIL_SCHEME="${MAIL_SCHEME:-null}"
export MAIL_FROM_NAME="${MAIL_FROM_NAME:-$APP_NAME}"
export HORIZON_NAME="${HORIZON_NAME:-treasure-api}"
export HORIZON_PREFIX="${HORIZON_PREFIX:-treasure_api_horizon:}"
: "${MAIL_HOST:?Missing MAIL_HOST GitLab CI/CD variable}"
: "${MAIL_PORT:?Missing MAIL_PORT GitLab CI/CD variable}"
: "${MAIL_USERNAME:?Missing MAIL_USERNAME GitLab CI/CD variable}"

View File

@ -8,10 +8,14 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.3.6
- php - 8.4.20
- filament/filament (FILAMENT) - v4
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/scout (SCOUT) - v10
- livewire/livewire (LIVEWIRE) - v3
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
@ -177,6 +181,89 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
## Livewire Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter

View File

@ -28,6 +28,10 @@ RUN docker-php-ext-configure pdo_mysql --with-pdo-mysql=mysqlnd \
&& docker-php-ext-enable pdo_mysql \
&& php -m | grep -i pdo_mysql
# 👉 Installation de l'extension Redis pour les queues Horizon
RUN if ! php -m | grep -qi '^redis$'; then pecl install redis && docker-php-ext-enable redis; fi \
&& php -m | grep -i redis
# 👉 Installation de l'extension intl
RUN apt-get update && apt-get install -y libicu-dev \
&& docker-php-ext-configure intl \

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Enums\HuntDifficulty;
use App\Models\Hunts;
use App\Notifications\HuntParticipantJoinedNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@ -59,7 +60,7 @@ class HuntsController extends Controller
$hunt->load([
'participants:id,username,avatar_uri,xp,level_id',
'participants.currentLevel',
'steps'
'steps',
]);
Log::info('HUNT_SHOW_SUCCESS', [
@ -108,7 +109,7 @@ class HuntsController extends Controller
$originalSlug = $validated['slug'];
$counter = 1;
while (Hunts::where('slug', $validated['slug'])->exists()) {
$validated['slug'] = $originalSlug . '-' . $counter;
$validated['slug'] = $originalSlug.'-'.$counter;
$counter++;
}
@ -143,7 +144,7 @@ class HuntsController extends Controller
try {
// Vérifier que l'utilisateur est le créateur ou admin
$user = auth('sanctum')->user();
if ($hunt->creator_id !== $user->id && !$user->isAdmin()) {
if ($hunt->creator_id !== $user->id && ! $user->isAdmin()) {
return response()->json([
'error' => 'Forbidden',
'message' => 'You are not authorized to update this hunt',
@ -172,7 +173,7 @@ class HuntsController extends Controller
$originalSlug = $validated['slug'];
$counter = 1;
while (Hunts::where('slug', $validated['slug'])->where('id', '!=', $hunt->id)->exists()) {
$validated['slug'] = $originalSlug . '-' . $counter;
$validated['slug'] = $originalSlug.'-'.$counter;
$counter++;
}
}
@ -209,7 +210,7 @@ class HuntsController extends Controller
try {
// Vérifier que l'utilisateur est le créateur ou admin
$user = auth('sanctum')->user();
if ($hunt->creator_id !== $user->id && !$user->isAdmin()) {
if ($hunt->creator_id !== $user->id && ! $user->isAdmin()) {
return response()->json([
'error' => 'Forbidden',
'message' => 'You are not authorized to delete this hunt',
@ -260,6 +261,12 @@ class HuntsController extends Controller
'status' => 'in_progress',
]);
$hunt->loadMissing('creator');
if ($hunt->creator !== null && $hunt->creator->getKey() !== $user->getKey()) {
$hunt->creator->notify(new HuntParticipantJoinedNotification($hunt, $user));
}
Log::info('HUNT_PARTICIPATE', [
'hunt_id' => $hunt->id,
'user_id' => $user->id,
@ -290,7 +297,7 @@ class HuntsController extends Controller
$user = auth('sanctum')->user();
// Vérifier si l'utilisateur participe
if (!$hunt->participants()->where('user_id', $user->id)->exists()) {
if (! $hunt->participants()->where('user_id', $user->id)->exists()) {
return response()->json([
'error' => 'Not Participating',
'message' => 'You are not participating in this hunt',
@ -347,7 +354,7 @@ class HuntsController extends Controller
$filters[] = "participant_ids != \"{$escapedUserId}\"";
}
} elseif ($participating === true) {
$filters[] = "id < 0";
$filters[] = 'id < 0';
}
}
@ -392,12 +399,12 @@ class HuntsController extends Controller
array_map('strtolower', $difficulties)
));
if (!empty($validDifficulties)) {
if (! empty($validDifficulties)) {
$difficultyFilters = array_map(
fn ($d) => 'difficulty = "' . $this->escapeMeiliString($d) . '"',
fn ($d) => 'difficulty = "'.$this->escapeMeiliString($d).'"',
$validDifficulties
);
$filters[] = '(' . implode(' OR ', $difficultyFilters) . ')';
$filters[] = '('.implode(' OR ', $difficultyFilters).')';
}
}
}
@ -429,7 +436,7 @@ class HuntsController extends Controller
// Cela évite l'erreur Meilisearch avec une chaîne de filtres vide
if (empty($filters)) {
// En Meilisearch, les booléens peuvent être comparés avec true/false (sans guillemets)
$filters[] = "is_public = true";
$filters[] = 'is_public = true';
}
$finalFilters = implode(' AND ', $filters);
@ -445,7 +452,7 @@ class HuntsController extends Controller
{
$searchTerm = $request->query('search') ?? $request->query('q');
if (!is_string($searchTerm)) {
if (! is_string($searchTerm)) {
return null;
}
@ -456,7 +463,7 @@ class HuntsController extends Controller
private function parseDateFilterUtc(mixed $value): ?Carbon
{
if (!is_string($value) || trim($value) === '') {
if (! is_string($value) || trim($value) === '') {
return null;
}
@ -471,6 +478,7 @@ class HuntsController extends Controller
{
// Échappe les backslashes et les guillemets pour une string Meilisearch
$value = str_replace('\\', '\\\\', $value);
return str_replace('"', '\"', $value);
}
}

View File

@ -6,6 +6,7 @@ use App\Enums\HuntDifficulty;
use App\Enums\HuntStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Scout\Searchable;
@ -51,6 +52,11 @@ class Hunts extends Model
return $this->hasMany(HuntSteps::class, 'hunt_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'creator_id');
}
public function participants(): BelongsToMany
{
return $this->belongsToMany(User::class, 'hunt_participants', 'hunt_id', 'user_id')

View File

@ -0,0 +1,37 @@
<?php
namespace App\Notifications;
use App\Models\Hunts;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HuntParticipantJoinedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Hunts $hunt,
public User $participant,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Nouveau participant sur '.$this->hunt->title)
->greeting('Bonjour '.$notifiable->username)
->line($this->participant->username.' participe maintenant à votre chasse "'.$this->hunt->title.'".')
->line('Vous pouvez suivre les participants depuis votre espace organisateur.');
}
}

View File

@ -4,12 +4,13 @@ namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
class VerifyEmailNotification extends Notification
class VerifyEmailNotification extends Notification implements ShouldQueue
{
use Queueable;

View File

@ -6,10 +6,12 @@ use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Support\Icons\Heroicon;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@ -41,6 +43,18 @@ class AdminPanelProvider extends PanelProvider
AccountWidget::class,
FilamentInfoWidget::class,
])
->navigationItems([
NavigationItem::make('Horizon')
->label('Queues')
->url(
fn (): string => '/'.trim((string) config('horizon.path', 'horizon'), '/'),
shouldOpenInNewTab: true,
)
->icon(Heroicon::OutlinedQueueList)
->group('Monitoring')
->sort(100)
->visible(fn (): bool => auth()->user()?->isAdmin() ?? false),
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,

View File

@ -0,0 +1,35 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function (?User $user = null): bool {
return $user?->isAdmin() ?? false;
});
}
}

View File

@ -3,4 +3,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\HorizonServiceProvider::class,
];

View File

@ -17,6 +17,10 @@ services:
XDEBUG_MODE: "${SAIL_XDEBUG_MODE:-off}"
XDEBUG_CONFIG: "${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}"
IGNITION_LOCAL_SITES_PATH: "${PWD}"
QUEUE_CONNECTION: redis
REDIS_HOST: redis
REDIS_CLIENT: phpredis
REDIS_QUEUE: default
volumes:
- ".:/var/www/html"
- "sail-storage:/var/www/html/storage/app"
@ -25,6 +29,36 @@ services:
depends_on:
- mysql
- meilisearch
- redis
horizon:
build:
context: "./vendor/laravel/sail/runtimes/8.4"
dockerfile: Dockerfile
args:
WWWGROUP: "${WWWGROUP}"
image: "sail-84/app"
extra_hosts:
- "host.docker.internal:host-gateway"
command: php artisan horizon
environment:
WWWUSER: "${WWWUSER}"
LARAVEL_SAIL: 1
XDEBUG_MODE: "${SAIL_XDEBUG_MODE:-off}"
XDEBUG_CONFIG: "${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}"
IGNITION_LOCAL_SITES_PATH: "${PWD}"
QUEUE_CONNECTION: redis
REDIS_HOST: redis
REDIS_CLIENT: phpredis
REDIS_QUEUE: default
volumes:
- ".:/var/www/html"
- "sail-storage:/var/www/html/storage/app"
networks:
- sail
depends_on:
- mysql
- meilisearch
- redis
mysql:
image: "mysql/mysql-server:8.0"
ports:
@ -72,6 +106,21 @@ services:
- "sail-meilisearch:/meili_data"
networks:
- sail
redis:
image: "redis:7-alpine"
ports:
- "${FORWARD_REDIS_PORT:-6380}:6379"
volumes:
- "sail-redis:/data"
networks:
- sail
healthcheck:
test:
- CMD
- redis-cli
- ping
retries: 3
timeout: 5s
networks:
sail:
@ -81,5 +130,7 @@ volumes:
driver: local
sail-meilisearch:
driver: local
sail-redis:
driver: local
sail-storage:
driver: local

View File

@ -16,6 +16,7 @@
"filament/filament": "^4.0",
"http-interop/http-factory-guzzle": "*",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.46",
"laravel/sanctum": "^4.0",
"laravel/scout": "*",
"laravel/tinker": "^2.10.1",
@ -54,7 +55,7 @@
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan horizon\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,horizon,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",

138
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8632b5fc1053d477088663c92da4db8c",
"content-hash": "d38e1d4ebc365fe7117cf31db8246bb8",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@ -2578,6 +2578,86 @@
},
"time": "2025-11-26T19:24:25+00:00"
},
{
"name": "laravel/horizon",
"version": "v5.46.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
"reference": "bfea968e8aa674fb649d02e55ea0d38bdf5137d5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/horizon/zipball/bfea968e8aa674fb649d02e55ea0d38bdf5137d5",
"reference": "bfea968e8aa674fb649d02e55ea0d38bdf5137d5",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-pcntl": "*",
"ext-posix": "*",
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0",
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0",
"laravel/sentinel": "^1.0",
"nesbot/carbon": "^2.17|^3.0",
"php": "^8.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/error-handler": "^6.0|^7.0|^8.0",
"symfony/polyfill-php83": "^1.28",
"symfony/process": "^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^1.10|^2.0",
"predis/predis": "^1.1|^2.0|^3.0"
},
"suggest": {
"ext-redis": "Required to use the Redis PHP driver.",
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Horizon": "Laravel\\Horizon\\Horizon"
},
"providers": [
"Laravel\\Horizon\\HorizonServiceProvider"
]
},
"branch-alias": {
"dev-master": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Horizon\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Dashboard and code-driven configuration for Laravel queues.",
"keywords": [
"laravel",
"queue"
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
"source": "https://github.com/laravel/horizon/tree/v5.46.0"
},
"time": "2026-04-20T18:08:11+00:00"
},
{
"name": "laravel/prompts",
"version": "v0.3.8",
@ -2781,6 +2861,62 @@
},
"time": "2025-12-16T15:43:03+00:00"
},
{
"name": "laravel/sentinel",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sentinel.git",
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sentinel/zipball/972d9885d9d14312a118e9565c4e6ecc5e751ea1",
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0"
},
"require-dev": {
"laravel/pint": "^1.27",
"orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^2.1.33"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sentinel\\SentinelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sentinel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Mior Muhammad Zaki",
"email": "mior@laravel.com"
}
],
"support": {
"source": "https://github.com/laravel/sentinel/tree/v1.1.0"
},
"time": "2026-03-24T14:03:38+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.7",

254
config/horizon.php Normal file
View File

@ -0,0 +1,254 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Name
|--------------------------------------------------------------------------
|
| This name appears in notifications and in the Horizon UI. Unique names
| can be useful while running multiple instances of Horizon within an
| application, allowing you to identify the Horizon you're viewing.
|
*/
'name' => env('HORIZON_NAME'),
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
'silenced_tags' => [
// 'notifications',
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-1' => [
'maxProcesses' => 3,
],
],
],
/*
|--------------------------------------------------------------------------
| File Watcher Configuration
|--------------------------------------------------------------------------
|
| The following list of directories and files will be watched when using
| the `horizon:listen` command. Whenever any directories or files are
| changed, Horizon will automatically restart to apply all changes.
|
*/
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'composer.json',
'.env',
],
];

View File

@ -13,7 +13,7 @@ return [
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
'default' => env('QUEUE_CONNECTION', 'redis'),
/*
|--------------------------------------------------------------------------

View File

@ -28,8 +28,62 @@ services:
MAIL_PASSWORD: "${MAIL_PASSWORD}"
MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS}"
MAIL_FROM_NAME: "${MAIL_FROM_NAME}"
QUEUE_CONNECTION: "redis"
REDIS_CLIENT: "phpredis"
REDIS_HOST: "redis"
REDIS_PASSWORD: "null"
REDIS_PORT: 6379
REDIS_QUEUE: "default"
HORIZON_NAME: "${HORIZON_NAME}"
HORIZON_PREFIX: "${HORIZON_PREFIX}"
volumes:
- storage-data:/var/www/html/storage/app
depends_on:
- mysql
- meilisearch
- redis
treasure-api-horizon:
image: registry.gitlab.com/treasure-hunt4/treasure-api:latest
restart: unless-stopped
command: php artisan horizon
environment:
APP_ENV: "production"
APP_KEY: "${APP_KEY}"
APP_URL: "${APP_URL}"
APP_DEBUG: "false"
SCOUT_DRIVER: "${SCOUT_DRIVER}"
MEILISEARCH_HOST: "${MEILISEARCH_HOST}"
MEILISEARCH_KEY: "${MEILISEARCH_KEY}"
DB_CONNECTION: "mysql"
DB_HOST: "mysql"
DB_PORT: 3306
DB_DATABASE: "${DB_DATABASE}"
DB_USERNAME: "${DB_USERNAME}"
DB_PASSWORD: "${DB_PASSWORD}"
MAIL_MAILER: "${MAIL_MAILER}"
MAIL_SCHEME: "${MAIL_SCHEME}"
MAIL_HOST: "${MAIL_HOST}"
MAIL_PORT: "${MAIL_PORT}"
MAIL_USERNAME: "${MAIL_USERNAME}"
MAIL_PASSWORD: "${MAIL_PASSWORD}"
MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS}"
MAIL_FROM_NAME: "${MAIL_FROM_NAME}"
QUEUE_CONNECTION: "redis"
REDIS_CLIENT: "phpredis"
REDIS_HOST: "redis"
REDIS_PASSWORD: "null"
REDIS_PORT: 6379
REDIS_QUEUE: "default"
HORIZON_NAME: "${HORIZON_NAME}"
HORIZON_PREFIX: "${HORIZON_PREFIX}"
volumes:
- storage-data:/var/www/html/storage/app
depends_on:
- treasure-api
- mysql
- meilisearch
- redis
# treasure-api-migrate:
# image: registry.gitlab.com/treasure-hunt4/treasure-api:latest
@ -124,7 +178,15 @@ services:
ports:
- "7700:7700"
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis-data:/data
volumes:
mysql-data:
meilisearch-data:
redis-data:
storage-data:

View File

@ -0,0 +1,66 @@
<?php
use App\Models\Hunts;
use App\Models\User;
use App\Notifications\HuntParticipantJoinedNotification;
use App\Notifications\VerifyEmailNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
it('notifies the hunt creator when a user joins their hunt', function () {
Notification::fake();
$creator = User::factory()->create();
$participant = User::factory()->create();
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
'creator_id' => $creator->id,
]));
$response = $this
->actingAs($participant, 'sanctum')
->postJson("/api/hunts/{$hunt->id}/participate");
$response
->assertOk()
->assertJson([
'message' => 'Successfully joined the hunt',
]);
Notification::assertSentTo(
$creator,
HuntParticipantJoinedNotification::class,
fn (HuntParticipantJoinedNotification $notification): bool => $notification->hunt->is($hunt)
&& $notification->participant->is($participant)
&& $notification instanceof ShouldQueue,
);
});
it('does not notify the hunt creator when the user already participates', function () {
Notification::fake();
$creator = User::factory()->create();
$participant = User::factory()->create();
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
'creator_id' => $creator->id,
]));
$hunt->participants()->attach($participant->id, [
'current_step_number' => 0,
'status' => 'in_progress',
]);
$response = $this
->actingAs($participant, 'sanctum')
->postJson("/api/hunts/{$hunt->id}/participate");
$response->assertConflict();
Notification::assertNotSentTo($creator, HuntParticipantJoinedNotification::class);
});
it('queues email verification notifications', function () {
expect(new VerifyEmailNotification)->toBeInstanceOf(ShouldQueue::class);
});