Как настроить фоновые задачи Laravel Queue для интернет-магазина (2016, Казахстан)
Коротко: QUEUE_DRIVER=redis в .env, создайте Job через php artisan make:job SendOrderNotification, dispatch через dispatch(new SendOrderNotification($order)), запустите worker php artisan queue:work. Для production - Supervisor управляет workers. Проблемы: Mobizon SMS API может зависнуть - добавьте tries=3 и timeout=30.
Что выносить в очередь (казахстанский контекст)
✓ SMS через Mobizon / SMSC.kz - 500-2000ms
✓ Email (SMTP через KazPost, mail.kz) - 1000-5000ms
✓ Генерация PDF чека - 800-3000ms
✓ Отправка данных в 1C (если интеграция) - нестабильно
✓ Push-уведомления FCM - 200-500ms (можно и синхронно)
✓ Обновление поисковых индексов - 500ms+
✗ НЕ выносить: проверка наличия товара (нужен мгновенный ответ)
✗ НЕ выносить: расчёт стоимости корзины
.env конфигурация
QUEUE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
# Несколько очередей с приоритетами
# high: SMS уведомления курьеру (срочно)
# default: email клиентам
# low: обновление индексов поиска
Создание Job
<?php
// app/Jobs/SendOrderSms.php
// php artisan make:job SendOrderSms
namespace App\Jobs;
use App\Models\Order;
use App\Services\SmsService;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendOrderSms implements ShouldQueue {
use InteractsWithQueue, Queueable, SerializesModels;
protected Order $order;
// Настройки повторных попыток
public int $tries = 3; // 3 попытки при ошибке
public int $timeout = 30; // Таймаут 30 секунд (Mobizon может тормозить)
public function __construct(Order $order) {
$this->order = $order;
$this->queue = 'high'; // Высокоприоритетная очередь для SMS
}
public function handle(SmsService $sms): void {
$phone = $this->normalizeKazakhPhone($this->order->customer_phone);
$text = "Заказ #{$this->order->id} подтверждён. "
. "Сумма: " . number_format($this->order->total, 0, '.', ' ') . " тг. "
. "Доставка " . $this->order->delivery_date . ". "
. "myshop.kz";
$sms->send($phone, $text);
}
public function failed(\Exception $exception): void {
// Вызывается после исчерпания попыток
\Log::error("SMS failed for order #{$this->order->id}: " . $exception->getMessage());
// Уведомить менеджера что SMS не дошёл
\Mail::to('manager@myshop.kz')->send(new SmsFailedNotification($this->order));
}
private function normalizeKazakhPhone(string $phone): string {
$phone = preg_replace('/[^0-9]/', '', $phone);
if (strlen($phone) === 10 && $phone[0] === '7') {
return '7' . $phone; // 7xxxxxxxxx → 77xxxxxxxxx
}
if (strlen($phone) === 10) {
return '7' . $phone; // 0xxxxxxxxx → 70xxxxxxxxx
}
return $phone;
}
}
Несколько типов Jobs
<?php
// app/Jobs/GenerateOrderPdf.php
class GenerateOrderPdf implements ShouldQueue {
use InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 60; // PDF генерация тяжелее SMS
public function __construct(
protected Order $order,
protected string $email
) {
$this->queue = 'default';
}
public function handle(): void {
// Генерировать PDF чек
$pdf = PDF::loadView('orders.receipt', ['order' => $this->order])
->setPaper('a4');
$pdfPath = storage_path("app/receipts/order_{$this->order->id}.pdf");
$pdf->save($pdfPath);
// Отправить email с PDF
\Mail::to($this->email)
->send(new OrderReceiptMail($this->order, $pdfPath));
}
}
// app/Jobs/UpdateSearchIndex.php
class UpdateSearchIndex implements ShouldQueue {
use InteractsWithQueue, Queueable, SerializesModels;
public function __construct(protected int $productId) {
$this->queue = 'low'; // Низкий приоритет
}
public function handle(SearchService $search): void {
$product = Product::find($this->productId);
if ($product) {
$search->indexProduct($product);
}
}
}
Dispatch Jobs из контроллера
<?php
// OrderController.php
public function store(CreateOrderRequest $request): JsonResponse {
$order = Order::create([
'user_id' => auth()->id(),
'total' => $request->total,
'status' => 'confirmed',
'customer_name' => $request->name,
'customer_phone' => $request->phone,
// ...
]);
// Диспатч в разные очереди
dispatch(new SendOrderSms($order)); // high queue, срочно
dispatch(new GenerateOrderPdf($order, auth()->user()->email)); // default queue
dispatch(new UpdateSearchIndex($order->product_id)); // low queue, не срочно
// Ответ пользователю: мгновенный, не ждём SMS/email
return response()->json([
'success' => true,
'order_id' => $order->id,
'message' => 'Заказ принят. SMS придёт в течение минуты.',
], 201);
}
Supervisor: запуск workers
; /etc/supervisor/conf.d/laravel-workers.conf
; Высокоприоритетные задачи: 2 worker
[program:laravel-high]
command=php /var/www/myshop/artisan queue:work redis --queue=high --sleep=3 --tries=3 --timeout=30
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/supervisor/laravel-high.log
; Обычные задачи: 2 worker
[program:laravel-default]
command=php /var/www/myshop/artisan queue:work redis --queue=default,high --sleep=3 --tries=3
autostart=true
autorestart=true
user=www-data
numprocs=2
stdout_logfile=/var/log/supervisor/laravel-default.log
; Низкоприоритетные: 1 worker
[program:laravel-low]
command=php /var/www/myshop/artisan queue:work redis --queue=low,default,high --sleep=5 --tries=2
autostart=true
autorestart=true
user=www-data
numprocs=1
stdout_logfile=/var/log/supervisor/laravel-low.log
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status
# После деплоя: перезапустить workers
php artisan queue:restart
# Workers прочитают сигнал из Redis и перезапустятся после текущей задачи
Failed Jobs: мониторинг ошибок
<?php
// Миграция для таблицы failed_jobs:
// php artisan queue:failed-table && php artisan migrate
// Просмотр упавших задач:
// php artisan queue:failed
// Повторить конкретную задачу:
// php artisan queue:retry 5
// Повторить все упавшие:
// php artisan queue:retry all
// Cron: ежечасно проверять failed jobs и алертить
// app/Console/Commands/CheckFailedJobs.php
public function handle(): void {
$count = DB::table('failed_jobs')
->where('failed_at', '>=', now()->subHour())
->count();
if ($count > 5) {
\Mail::to('ops@myshop.kz')
->send(new FailedJobsAlert($count));
}
}
Результаты после внедрения Queue
| Операция | До | После |
|---|---|---|
| Ответ на создание заказа | 4.2 с | 0.18 с |
| SMS через Mobizon | синхронно | фоново |
| Email с PDF чеком | синхронно | фоново, ~30 сек |
| Конверсия чекаута | 68% | 79% (+11%) |
Пользователи, которые раньше ждали 4+ секунд перед страницей «Заказ принят», теперь попадали туда за 200ms. Это и дало рост конверсии.