diff --git a/.env.example b/.env.example
index 9dba2da..f126936 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 3ae7203..58a324b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -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}"
diff --git a/AGENTS.md b/AGENTS.md
index e5db0e3..ad6d3b1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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)
+
+ {{ $item->name }}
+
+ @endforeach
+ ```
+
+- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
+
+
+ public function mount(User $user) { $this->user = $user; }
+ public function updatedSearch() { $this->resetPage(); }
+
+
+
+## Testing Livewire
+
+
+ Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1)
+ ->assertSee(1)
+ ->assertStatus(200);
+
+
+
+
+ $this->get('/posts/create')
+ ->assertSeeLivewire(CreatePost::class);
+
+
+
+=== 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:
+
+
+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);
+ });
+});
+
+
+
=== pint/core rules ===
## Laravel Pint Code Formatter
diff --git a/Dockerfile b/Dockerfile
index 882a161..b37ffbb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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 \
diff --git a/app/Http/Controllers/HuntsController.php b/app/Http/Controllers/HuntsController.php
index 4846a2d..ad015e0 100644
--- a/app/Http/Controllers/HuntsController.php
+++ b/app/Http/Controllers/HuntsController.php
@@ -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;
@@ -30,7 +31,7 @@ class HuntsController extends Controller
]);
$result = $search->paginate(10);
-
+
Log::info('HUNTS_SEARCH_SUCCESS', [
'total' => $result->total(),
'count' => count($result->items()),
@@ -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',
@@ -167,12 +168,12 @@ class HuntsController extends Controller
// Update slug if title changed
if (isset($validated['title']) && $validated['title'] !== $hunt->title) {
$validated['slug'] = \Illuminate\Support\Str::slug($validated['title']);
-
+
// Ensure slug is unique
$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,21 +399,21 @@ 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).')';
}
}
}
// dates (UTC)
$startFrom = $this->parseDateFilterUtc($request->query('start_from'));
- $startTo = $this->parseDateFilterUtc($request->query('start_to'));
- $endFrom = $this->parseDateFilterUtc($request->query('end_from'));
- $endTo = $this->parseDateFilterUtc($request->query('end_to'));
+ $startTo = $this->parseDateFilterUtc($request->query('start_to'));
+ $endFrom = $this->parseDateFilterUtc($request->query('end_from'));
+ $endTo = $this->parseDateFilterUtc($request->query('end_to'));
if ($startFrom) {
// start_at_timestamp est toujours présent (created_at par défaut), donc pas besoin de EXISTS
@@ -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);
}
}
diff --git a/app/Models/Hunts.php b/app/Models/Hunts.php
index 39fe596..d10a6ef 100644
--- a/app/Models/Hunts.php
+++ b/app/Models/Hunts.php
@@ -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')
diff --git a/app/Notifications/HuntParticipantJoinedNotification.php b/app/Notifications/HuntParticipantJoinedNotification.php
new file mode 100644
index 0000000..585773c
--- /dev/null
+++ b/app/Notifications/HuntParticipantJoinedNotification.php
@@ -0,0 +1,37 @@
+
+ */
+ 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.');
+ }
+}
diff --git a/app/Notifications/VerifyEmailNotification.php b/app/Notifications/VerifyEmailNotification.php
index 88bfcfb..82737ad 100644
--- a/app/Notifications/VerifyEmailNotification.php
+++ b/app/Notifications/VerifyEmailNotification.php
@@ -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;
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index 8ce4eb0..48d741f 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -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,
diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php
new file mode 100644
index 0000000..526e3ea
--- /dev/null
+++ b/app/Providers/HorizonServiceProvider.php
@@ -0,0 +1,35 @@
+isAdmin() ?? false;
+ });
+ }
+}
diff --git a/bootstrap/providers.php b/bootstrap/providers.php
index 22744d1..2a33f62 100644
--- a/bootstrap/providers.php
+++ b/bootstrap/providers.php
@@ -3,4 +3,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
+ App\Providers\HorizonServiceProvider::class,
];
diff --git a/compose.yaml b/compose.yaml
index 53ad614..d605e33 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -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
diff --git a/composer.json b/composer.json
index db777df..f3f0b05 100644
--- a/composer.json
+++ b/composer.json
@@ -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",
diff --git a/composer.lock b/composer.lock
index e6e2a31..9590b37 100644
--- a/composer.lock
+++ b/composer.lock
@@ -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",
diff --git a/config/horizon.php b/config/horizon.php
new file mode 100644
index 0000000..32f8f0d
--- /dev/null
+++ b/config/horizon.php
@@ -0,0 +1,254 @@
+ 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',
+ ],
+];
diff --git a/config/queue.php b/config/queue.php
index 79c2c0a..a0f669c 100644
--- a/config/queue.php
+++ b/config/queue.php
@@ -13,7 +13,7 @@ return [
|
*/
- 'default' => env('QUEUE_CONNECTION', 'database'),
+ 'default' => env('QUEUE_CONNECTION', 'redis'),
/*
|--------------------------------------------------------------------------
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 1c2f413..f23befb 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -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:
diff --git a/tests/Feature/HuntParticipationNotificationTest.php b/tests/Feature/HuntParticipationNotificationTest.php
new file mode 100644
index 0000000..d3236a5
--- /dev/null
+++ b/tests/Feature/HuntParticipationNotificationTest.php
@@ -0,0 +1,66 @@
+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);
+});