Патерн Command перетворює виклик методу на обʼєкт: PublishVacancy з ідентифікатором вакансії й автором дії. Метод виконується лише тут і зараз, а обʼєкт можна передати далі: у шину, у чергу, у журнал, у повтор після збою. Тому команда несе тільки дані, потрібні для виконання, і в ній немає ні моделі цілком, ні сервісів. Обробник отримує команду, читає свіжий стан, перевіряє правила й виконує дію.
Command bus додає до цього одне місце, через яке проходять усі команди. Шина за класом команди знаходить обробник, тож контролер, Artisan-команда й бот не знають, хто саме виконає дію, і викликають її однаково. Друга вигода - middleware навколо кожної команди: транзакція, логування, валідація, метрики пишуться один раз, а не в кожному обробнику. У Laravel шина вже є: Bus::dispatch() для класу без ShouldQueue виконує команду синхронно й повертає результат, Bus::map() відокремлює обробник, Bus::pipeThrough() задає middleware. У Symfony ту саму роль грає Messenger: повідомлення окремим класом, обробник з #[AsMessageHandler], middleware на шині.
Queued job у Laravel - це та сама команда, у якої обробник живе в її ж методі handle(), а доставку через чергу вмикає ShouldQueue. Messenger розділяє ці речі: код викликає dispatch(), а чи виконати повідомлення одразу, чи відправити на транспорт, вирішує routing у конфігу. В обох випадках відкладене виконання змінює вимоги до обробника. Дані можуть застаріти, тож обробник перечитує стан за ідентифікатором. Доставка at-least-once, тож повтор не повинен опублікувати вакансію двічі. А постановка в чергу всередині транзакції має чекати коміту, у Laravel через afterCommit() чи after_commit у конфігу зʼєднання черги.
Шина окупається не завжди. Якщо дія викликається з одного місця, швидка й черга їй не потрібна, команда, обробник і шина лише розносять три рядки по трьох файлах, і прямий виклик сервісу застосунку читається краще. Окремі CommandBus і QueryBus мають сенс, коли в них справді різні middleware, наприклад транзакція лише для команд. Поділ «для порядку», з маркерними інтерфейсами на кожен клас, додає церемонію без вигоди.
// Команда: намір і дані, лише ідентифікатори й скаляри
final readonly class PublishVacancy
{
public function __construct(public int $vacancyId, public int $publishedBy) {}
}
// Обробник окремо: свіжий стан, правила домену, ідемпотентність
final class PublishVacancyHandler
{
public function __construct(private VacancyRepository $vacancies) {}
public function handle(PublishVacancy $command): string
{
$vacancy = $this->vacancies->get($command->vacancyId);
if ($vacancy->isPublished()) {
return 'вже опубліковано'; // повтор з черги нічого не ламає
}
$vacancy->publish($command->publishedBy);
$this->vacancies->save($vacancy);
return 'опубліковано';
}
}
// AppServiceProvider::boot(): шина знаходить обробник за класом команди
Bus::map([PublishVacancy::class => PublishVacancyHandler::class]);
Bus::pipeThrough([WrapInTransaction::class]); // ваш pipe: handle($command, $next)
// Контролер, Artisan-команда й бот: синхронно, з результатом
$status = Bus::dispatch(new PublishVacancy($vacancyId, $userId));
// Відкладено: job з ShouldQueue - команда й обробник в одному класі
final class PublishVacancyLater implements ShouldQueue
{
use Queueable;
public function __construct(public int $vacancyId, public int $publishedBy) {}
public function handle(Dispatcher $bus): void
{
$bus->dispatch(new PublishVacancy($this->vacancyId, $this->publishedBy));
}
}
Скажіть, що команда - це обʼєкт-намір, а шина - місце для всього, що має відбуватися навколо будь-якої команди. Потім поставте поруч Laravel job (команда й обробник разом, черга через `ShouldQueue`) і Messenger (окремо, транспорт у конфігу). Закінчіть ідемпотентністю обробника: саме вона показує, що ви запускали це в продакшені.