├── pint.json ├── src ├── Contracts │ └── RabbitMQQueueContract.php ├── Queue │ ├── QueueFactory.php │ ├── Connectors │ │ └── RabbitMQConnector.php │ ├── QueueConfigFactory.php │ ├── Jobs │ │ └── RabbitMQJob.php │ ├── Connection │ │ ├── ConfigFactory.php │ │ └── ConnectionFactory.php │ ├── QueueConfig.php │ └── RabbitMQQueue.php ├── Console │ ├── QueuePurgeCommand.php │ ├── QueueBindCommand.php │ ├── ExchangeDeleteCommand.php │ ├── QueueDeleteCommand.php │ ├── ExchangeDeclareCommand.php │ ├── QueueDeclareCommand.php │ └── ConsumeCommand.php ├── Horizon │ ├── Listeners │ │ └── RabbitMQFailedEvent.php │ └── RabbitMQQueue.php ├── LaravelQueueRabbitMQServiceProvider.php └── Consumer.php ├── config └── rabbitmq.php ├── LICENSE.md ├── composer.json └── README.md /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "php_unit_method_casing": { 5 | "case": "camel_case" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Contracts/RabbitMQQueueContract.php: -------------------------------------------------------------------------------- 1 | 'rabbitmq', 11 | 'queue' => env('RABBITMQ_QUEUE', 'default'), 12 | 'connection' => 'default', 13 | 14 | 'hosts' => [ 15 | [ 16 | 'host' => env('RABBITMQ_HOST', '127.0.0.1'), 17 | 'port' => env('RABBITMQ_PORT', 5672), 18 | 'user' => env('RABBITMQ_USER', 'guest'), 19 | 'password' => env('RABBITMQ_PASSWORD', 'guest'), 20 | 'vhost' => env('RABBITMQ_VHOST', '/'), 21 | ], 22 | ], 23 | 24 | 'options' => [ 25 | ], 26 | 27 | /* 28 | * Set to "horizon" if you wish to use Laravel Horizon. 29 | */ 30 | 'worker' => env('RABBITMQ_WORKER', 'default'), 31 | 32 | ]; 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Vladimir Yuldashev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Console/QueuePurgeCommand.php: -------------------------------------------------------------------------------- 1 | confirmToProceed()) { 27 | return; 28 | } 29 | 30 | $config = $this->laravel['config']->get('queue.connections.'.$this->argument('connection')); 31 | 32 | $queue = $connector->connect($config); 33 | 34 | $queue->purge($this->argument('queue')); 35 | 36 | $this->info('Queue purged successfully.'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Horizon/Listeners/RabbitMQFailedEvent.php: -------------------------------------------------------------------------------- 1 | events = $events; 27 | } 28 | 29 | /** 30 | * Handle the event. 31 | */ 32 | public function handle(LaravelJobFailed $event): void 33 | { 34 | if (! $event->job instanceof RabbitMQJob) { 35 | return; 36 | } 37 | 38 | $this->events->dispatch((new HorizonJobFailed( 39 | $event->exception, 40 | $event->job, 41 | $event->job->getRawBody() 42 | ))->connection($event->connectionName)->queue($event->job->getQueue())); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Console/QueueBindCommand.php: -------------------------------------------------------------------------------- 1 | laravel['config']->get('queue.connections.'.$this->argument('connection')); 25 | 26 | $queue = $connector->connect($config); 27 | 28 | $queue->bindQueue( 29 | $this->argument('queue'), 30 | $this->argument('exchange'), 31 | (string) $this->option('routing-key') 32 | ); 33 | 34 | $this->info('Queue bound to exchange successfully.'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Console/ExchangeDeleteCommand.php: -------------------------------------------------------------------------------- 1 | laravel['config']->get('queue.connections.'.$this->argument('connection')); 24 | 25 | $queue = $connector->connect($config); 26 | 27 | if (! $queue->isExchangeExists($this->argument('name'))) { 28 | $this->warn('Exchange does not exist.'); 29 | 30 | return; 31 | } 32 | 33 | $queue->deleteExchange( 34 | $this->argument('name'), 35 | (bool) $this->option('unused') 36 | ); 37 | 38 | $this->info('Exchange deleted successfully.'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Console/QueueDeleteCommand.php: -------------------------------------------------------------------------------- 1 | laravel['config']->get('queue.connections.'.$this->argument('connection')); 25 | 26 | $queue = $connector->connect($config); 27 | 28 | if (! $queue->isQueueExists($this->argument('name'))) { 29 | $this->warn('Queue does not exist.'); 30 | 31 | return; 32 | } 33 | 34 | $queue->deleteQueue( 35 | $this->argument('name'), 36 | (bool) $this->option('unused'), 37 | (bool) $this->option('empty') 38 | ); 39 | 40 | $this->info('Queue deleted successfully.'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Console/ExchangeDeclareCommand.php: -------------------------------------------------------------------------------- 1 | laravel['config']->get('queue.connections.'.$this->argument('connection')); 26 | 27 | $queue = $connector->connect($config); 28 | 29 | if ($queue->isExchangeExists($this->argument('name'))) { 30 | $this->warn('Exchange already exists.'); 31 | 32 | return; 33 | } 34 | 35 | $queue->declareExchange( 36 | $this->argument('name'), 37 | $this->option('type'), 38 | (bool) $this->option('durable'), 39 | (bool) $this->option('auto-delete') 40 | ); 41 | 42 | $this->info('Exchange declared successfully.'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Queue/Connectors/RabbitMQConnector.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 24 | } 25 | 26 | /** 27 | * Establish a queue connection. 28 | * 29 | * @return RabbitMQQueue 30 | * 31 | * @throws Exception 32 | */ 33 | public function connect(array $config): Queue 34 | { 35 | $connection = ConnectionFactory::make($config); 36 | 37 | $queue = QueueFactory::make($config)->setConnection($connection); 38 | 39 | if ($queue instanceof HorizonRabbitMQQueue) { 40 | $this->dispatcher->listen(JobFailed::class, RabbitMQFailedEvent::class); 41 | } 42 | 43 | $this->dispatcher->listen(WorkerStopping::class, static function () use ($queue): void { 44 | $queue->close(); 45 | }); 46 | 47 | return $queue; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Console/QueueDeclareCommand.php: -------------------------------------------------------------------------------- 1 | laravel['config']->get('queue.connections.'.$this->argument('connection')); 27 | 28 | $queue = $connector->connect($config); 29 | 30 | if ($queue->isQueueExists($this->argument('name'))) { 31 | $this->warn('Queue already exists.'); 32 | 33 | return; 34 | } 35 | 36 | $arguments = []; 37 | 38 | $maxPriority = (int) $this->option('max-priority'); 39 | if ($maxPriority) { 40 | $arguments['x-max-priority'] = $maxPriority; 41 | } 42 | 43 | if ($this->option('quorum')) { 44 | $arguments['x-queue-type'] = 'quorum'; 45 | } 46 | 47 | $queue->declareQueue( 48 | $this->argument('name'), 49 | (bool) $this->option('durable'), 50 | (bool) $this->option('auto-delete'), 51 | $arguments 52 | ); 53 | 54 | $this->info('Queue declared successfully.'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vladimir-yuldashev/laravel-queue-rabbitmq", 3 | "description": "RabbitMQ driver for Laravel Queue. Supports Laravel Horizon.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Vladimir Yuldashev", 8 | "email": "misterio92@gmail.com" 9 | } 10 | ], 11 | "require": { 12 | "php": "^8.0", 13 | "ext-json": "*", 14 | "illuminate/queue": "^10.0|^11.0|^12.0", 15 | "php-amqplib/php-amqplib": "^v3.6" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^10.0|^11.0", 19 | "mockery/mockery": "^1.0", 20 | "laravel/horizon": "^5.0", 21 | "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", 22 | "laravel/pint": "^1.2", 23 | "laravel/framework": "^9.0|^10.0|^11.0|^12.0" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "VladimirYuldashev\\LaravelQueueRabbitMQ\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "VladimirYuldashev\\LaravelQueueRabbitMQ\\Tests\\": "tests/" 33 | } 34 | }, 35 | "extra": { 36 | "branch-alias": { 37 | "dev-master": "13.0-dev" 38 | }, 39 | "laravel": { 40 | "providers": [ 41 | "VladimirYuldashev\\LaravelQueueRabbitMQ\\LaravelQueueRabbitMQServiceProvider" 42 | ] 43 | } 44 | }, 45 | "suggest": { 46 | "ext-pcntl": "Required to use all features of the queue consumer." 47 | }, 48 | "scripts": { 49 | "test": [ 50 | "@test:style", 51 | "@test:unit" 52 | ], 53 | "test:style": "@php vendor/bin/pint --test -v", 54 | "test:unit": "@php vendor/bin/phpunit", 55 | "fix:style": "@php vendor/bin/pint -v" 56 | }, 57 | "minimum-stability": "dev", 58 | "prefer-stable": true 59 | } 60 | -------------------------------------------------------------------------------- /src/LaravelQueueRabbitMQServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 19 | __DIR__.'/../config/rabbitmq.php', 20 | 'queue.connections.rabbitmq' 21 | ); 22 | 23 | if ($this->app->runningInConsole()) { 24 | $this->app->singleton('rabbitmq.consumer', function () { 25 | $isDownForMaintenance = function () { 26 | return $this->app->isDownForMaintenance(); 27 | }; 28 | 29 | return new Consumer( 30 | $this->app['queue'], 31 | $this->app['events'], 32 | $this->app[ExceptionHandler::class], 33 | $isDownForMaintenance 34 | ); 35 | }); 36 | 37 | $this->app->singleton(ConsumeCommand::class, static function ($app) { 38 | return new ConsumeCommand( 39 | $app['rabbitmq.consumer'], 40 | $app['cache.store'] 41 | ); 42 | }); 43 | 44 | $this->commands([ 45 | Console\ConsumeCommand::class, 46 | ]); 47 | } 48 | 49 | $this->commands([ 50 | Console\ExchangeDeclareCommand::class, 51 | Console\ExchangeDeleteCommand::class, 52 | Console\QueueBindCommand::class, 53 | Console\QueueDeclareCommand::class, 54 | Console\QueueDeleteCommand::class, 55 | Console\QueuePurgeCommand::class, 56 | ]); 57 | } 58 | 59 | /** 60 | * Register the application's event listeners. 61 | */ 62 | public function boot(): void 63 | { 64 | /** @var QueueManager $queue */ 65 | $queue = $this->app['queue']; 66 | 67 | $queue->addConnector('rabbitmq', function () { 68 | return new RabbitMQConnector($this->app['events']); 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Queue/QueueConfigFactory.php: -------------------------------------------------------------------------------- 1 | setQueue($queue); 19 | } 20 | if (! empty($afterCommit = Arr::get($config, 'after_commit'))) { 21 | $queueConfig->setDispatchAfterCommit($afterCommit); 22 | } 23 | 24 | self::getOptionsFromConfig($queueConfig, $config); 25 | }); 26 | } 27 | 28 | protected static function getOptionsFromConfig(QueueConfig $queueConfig, array $config): void 29 | { 30 | $queueOptions = Arr::get($config, self::CONFIG_OPTIONS.'.queue', []) ?: []; 31 | 32 | if ($job = Arr::pull($queueOptions, 'job')) { 33 | $queueConfig->setAbstractJob($job); 34 | } 35 | 36 | // Feature: Prioritize delayed messages. 37 | if ($prioritizeDelayed = Arr::pull($queueOptions, 'prioritize_delayed')) { 38 | $queueConfig->setPrioritizeDelayed($prioritizeDelayed); 39 | } 40 | if ($maxPriority = Arr::pull($queueOptions, 'queue_max_priority')) { 41 | $queueConfig->setQueueMaxPriority($maxPriority); 42 | } 43 | 44 | // Feature: Working with Exchange and routing-keys 45 | if ($exchange = Arr::pull($queueOptions, 'exchange')) { 46 | $queueConfig->setExchange($exchange); 47 | } 48 | if ($exchangeType = Arr::pull($queueOptions, 'exchange_type')) { 49 | $queueConfig->setExchangeType($exchangeType); 50 | } 51 | if ($exchangeRoutingKey = Arr::pull($queueOptions, 'exchange_routing_key')) { 52 | $queueConfig->setExchangeRoutingKey($exchangeRoutingKey); 53 | } 54 | 55 | // Feature: Reroute failed messages 56 | if ($rerouteFailed = Arr::pull($queueOptions, 'reroute_failed')) { 57 | $queueConfig->setRerouteFailed($rerouteFailed); 58 | } 59 | if ($failedExchange = Arr::pull($queueOptions, 'failed_exchange')) { 60 | $queueConfig->setFailedExchange($failedExchange); 61 | } 62 | if ($failedRoutingKey = Arr::pull($queueOptions, 'failed_routing_key')) { 63 | $queueConfig->setFailedRoutingKey($failedRoutingKey); 64 | } 65 | 66 | // Feature: Mark queue as quorum 67 | if ($quorum = Arr::pull($queueOptions, 'quorum')) { 68 | $queueConfig->setQuorum($quorum); 69 | } 70 | 71 | // All extra options not defined 72 | $queueConfig->setOptions($queueOptions); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Console/ConsumeCommand.php: -------------------------------------------------------------------------------- 1 | worker; 41 | 42 | $consumer->setContainer($this->laravel); 43 | $consumer->setName($this->option('name')); 44 | $consumer->setConsumerTag($this->consumerTag()); 45 | $consumer->setMaxPriority((int) $this->option('max-priority')); 46 | $consumer->setPrefetchSize((int) $this->option('prefetch-size')); 47 | $consumer->setPrefetchCount((int) $this->option('prefetch-count')); 48 | 49 | parent::handle(); 50 | } 51 | 52 | protected function consumerTag(): string 53 | { 54 | if ($consumerTag = $this->option('consumer-tag')) { 55 | return $consumerTag; 56 | } 57 | 58 | $consumerTag = implode('_', [ 59 | Str::slug(config('app.name', 'laravel')), 60 | Str::slug($this->option('name')), 61 | md5(serialize($this->options()).Str::random(16).getmypid()), 62 | ]); 63 | 64 | return Str::substr($consumerTag, 0, 255); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Horizon/RabbitMQQueue.php: -------------------------------------------------------------------------------- 1 | size($queue); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function push($job, $data = '', $queue = null) 38 | { 39 | $this->lastPushed = $job; 40 | 41 | return parent::push($job, $data, $queue); 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | * 47 | * @throws BindingResolutionException 48 | */ 49 | public function pushRaw($payload, $queue = null, array $options = []): int|string|null 50 | { 51 | $payload = (new JobPayload($payload))->prepare($this->lastPushed ?? null)->value; 52 | 53 | return tap(parent::pushRaw($payload, $queue, $options), function () use ($queue, $payload): void { 54 | $this->event($this->getQueue($queue), new JobPushed($payload)); 55 | }); 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | * 61 | * @throws BindingResolutionException 62 | */ 63 | public function later($delay, $job, $data = '', $queue = null): mixed 64 | { 65 | $payload = (new JobPayload($this->createPayload($job, $data)))->prepare($job)->value; 66 | 67 | return tap(parent::laterRaw($delay, $payload, $queue), function () use ($payload, $queue): void { 68 | $this->event($this->getQueue($queue), new JobPushed($payload)); 69 | }); 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function pop($queue = null) 76 | { 77 | return tap(parent::pop($queue), function ($result) use ($queue): void { 78 | if (is_a($result, RabbitMQJob::class, true)) { 79 | $this->event($this->getQueue($queue), new JobReserved($result->getRawBody())); 80 | } 81 | }); 82 | } 83 | 84 | /** 85 | * Fire the job deleted event. 86 | * 87 | * @param string $queue 88 | * @param RabbitMQJob $job 89 | * 90 | * @throws BindingResolutionException 91 | */ 92 | public function deleteReserved($queue, $job): void 93 | { 94 | $this->event($this->getQueue($queue), new JobDeleted($job, $job->getRawBody())); 95 | } 96 | 97 | /** 98 | * Fire the given event if a dispatcher is bound. 99 | * 100 | * @param string $queue 101 | * @param mixed $event 102 | * 103 | * @throws BindingResolutionException 104 | */ 105 | protected function event($queue, $event): void 106 | { 107 | if ($this->container && $this->container->bound(Dispatcher::class)) { 108 | $this->container->make(Dispatcher::class)->dispatch( 109 | $event->connection($this->getConnectionName())->queue($queue) 110 | ); 111 | } 112 | } 113 | 114 | /** 115 | * {@inheritdoc} 116 | */ 117 | protected function getRandomId(): string 118 | { 119 | return Str::uuid(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Queue/Jobs/RabbitMQJob.php: -------------------------------------------------------------------------------- 1 | container = $container; 47 | $this->rabbitmq = $rabbitmq; 48 | $this->message = $message; 49 | $this->connectionName = $connectionName; 50 | $this->queue = $queue; 51 | $this->decoded = $this->payload(); 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function getJobId() 58 | { 59 | return $this->decoded['id'] ?? null; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function getRawBody(): string 66 | { 67 | return $this->message->getBody(); 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | */ 73 | public function attempts(): int 74 | { 75 | if (! $data = $this->getRabbitMQMessageHeaders()) { 76 | return 1; 77 | } 78 | 79 | $laravelAttempts = (int) Arr::get($data, 'laravel.attempts', 0); 80 | 81 | return $laravelAttempts + 1; 82 | } 83 | 84 | /** 85 | * {@inheritdoc} 86 | */ 87 | public function markAsFailed(): void 88 | { 89 | parent::markAsFailed(); 90 | 91 | // We must tel rabbitMQ this Job is failed 92 | // The message must be rejected when the Job marked as failed, in case rabbitMQ wants to do some extra magic. 93 | // like: Death lettering the message to an other exchange/routing-key. 94 | $this->rabbitmq->reject($this); 95 | } 96 | 97 | /** 98 | * {@inheritdoc} 99 | * 100 | * @throws BindingResolutionException 101 | */ 102 | public function delete(): void 103 | { 104 | parent::delete(); 105 | 106 | // When delete is called and the Job was not failed, the message must be acknowledged. 107 | // This is because this is a controlled call by a developer. So the message was handled correct. 108 | if (! $this->failed) { 109 | $this->rabbitmq->ack($this); 110 | } 111 | 112 | // required for Laravel Horizon 113 | if ($this->rabbitmq instanceof HorizonRabbitMQQueue) { 114 | $this->rabbitmq->deleteReserved($this->queue, $this); 115 | } 116 | } 117 | 118 | /** 119 | * Release the job back into the queue. 120 | * 121 | * @param int $delay 122 | * 123 | * @throws AMQPProtocolChannelException 124 | */ 125 | public function release($delay = 0): void 126 | { 127 | parent::release(); 128 | 129 | // Always create a new message when this Job is released 130 | $this->rabbitmq->laterRaw($delay, $this->message->getBody(), $this->queue, $this->attempts()); 131 | 132 | // Releasing a Job means the message was failed to process. 133 | // Because this Job message is always recreated and pushed as new message, this Job message is correctly handled. 134 | // We must tell rabbitMQ this job message can be removed by acknowledging the message. 135 | $this->rabbitmq->ack($this); 136 | } 137 | 138 | /** 139 | * Get the underlying RabbitMQ connection. 140 | */ 141 | public function getRabbitMQ(): RabbitMQQueue 142 | { 143 | return $this->rabbitmq; 144 | } 145 | 146 | /** 147 | * Get the underlying RabbitMQ message. 148 | */ 149 | public function getRabbitMQMessage(): AMQPMessage 150 | { 151 | return $this->message; 152 | } 153 | 154 | /** 155 | * Get the headers from the rabbitMQ message. 156 | */ 157 | protected function getRabbitMQMessageHeaders(): ?array 158 | { 159 | /** @var AMQPTable|null $headers */ 160 | if (! $headers = Arr::get($this->message->get_properties(), 'application_headers')) { 161 | return null; 162 | } 163 | 164 | return $headers->getNativeData(); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Queue/Connection/ConfigFactory.php: -------------------------------------------------------------------------------- 1 | setIsLazy(! in_array( 22 | Arr::get($config, 'lazy') ?? true, 23 | [false, 0, '0', 'false', 'no'], 24 | true) 25 | ); 26 | 27 | // Set the connection to unsecure by default 28 | $connectionConfig->setIsSecure(in_array( 29 | Arr::get($config, 'secure'), 30 | [true, 1, '1', 'true', 'yes'], 31 | true) 32 | ); 33 | 34 | if ($connectionConfig->isSecure()) { 35 | self::getSLLOptionsFromConfig($connectionConfig, $config); 36 | } 37 | 38 | self::getHostFromConfig($connectionConfig, $config); 39 | self::getHeartbeatFromConfig($connectionConfig, $config); 40 | self::getNetworkProtocolFromConfig($connectionConfig, $config); 41 | self::getTimeoutsFromConfig($connectionConfig, $config); 42 | }); 43 | } 44 | 45 | protected static function getHostFromConfig(AMQPConnectionConfig $connectionConfig, array $config): void 46 | { 47 | $hostConfig = Arr::first(Arr::shuffle(Arr::get($config, self::CONFIG_HOSTS, [])), null, []); 48 | 49 | if ($location = Arr::get($hostConfig, 'host')) { 50 | $connectionConfig->setHost($location); 51 | } 52 | if ($port = Arr::get($hostConfig, 'port')) { 53 | $connectionConfig->setPort($port); 54 | } 55 | if ($vhost = Arr::get($hostConfig, 'vhost')) { 56 | $connectionConfig->setVhost($vhost); 57 | } 58 | if ($user = Arr::get($hostConfig, 'user')) { 59 | $connectionConfig->setUser($user); 60 | } 61 | if ($password = Arr::get($hostConfig, 'password')) { 62 | $connectionConfig->setPassword($password); 63 | } 64 | } 65 | 66 | protected static function getSLLOptionsFromConfig(AMQPConnectionConfig $connectionConfig, array $config): void 67 | { 68 | $sslConfig = Arr::get($config, self::CONFIG_OPTIONS.'.ssl_options', []); 69 | 70 | if ($caFile = Arr::get($sslConfig, 'cafile')) { 71 | $connectionConfig->setSslCaCert($caFile); 72 | } 73 | if ($cert = Arr::get($sslConfig, 'local_cert')) { 74 | $connectionConfig->setSslCert($cert); 75 | } 76 | if ($key = Arr::get($sslConfig, 'local_key')) { 77 | $connectionConfig->setSslKey($key); 78 | } 79 | if (Arr::has($sslConfig, 'verify_peer')) { 80 | $verifyPeer = Arr::get($sslConfig, 'verify_peer'); 81 | $connectionConfig->setSslVerify($verifyPeer); 82 | } 83 | if ($passphrase = Arr::get($sslConfig, 'passphrase')) { 84 | $connectionConfig->setSslPassPhrase($passphrase); 85 | } 86 | } 87 | 88 | protected static function getHeartbeatFromConfig(AMQPConnectionConfig $connectionConfig, array $config): void 89 | { 90 | $heartbeat = Arr::get($config, self::CONFIG_OPTIONS.'.heartbeat'); 91 | 92 | if (is_numeric($heartbeat) && intval($heartbeat) > 0) { 93 | $connectionConfig->setHeartbeat((int) $heartbeat); 94 | } 95 | } 96 | 97 | protected static function getNetworkProtocolFromConfig(AMQPConnectionConfig $connectionConfig, array $config): void 98 | { 99 | if ($networkProtocol = Arr::get($config, 'network_protocol')) { 100 | $connectionConfig->setNetworkProtocol($networkProtocol); 101 | } 102 | } 103 | 104 | protected static function getTimeoutsFromConfig(AMQPConnectionConfig $connectionConfig, array $config): void 105 | { 106 | $connectionTimeout = Arr::get($config, self::CONFIG_OPTIONS.'.connection_timeout'); 107 | if (is_numeric($connectionTimeout) && floatval($connectionTimeout) >= 0) { 108 | $connectionConfig->setConnectionTimeout((float) $connectionTimeout); 109 | } 110 | 111 | $readTimeout = Arr::get($config, self::CONFIG_OPTIONS.'.read_timeout'); 112 | if (is_numeric($readTimeout) && floatval($readTimeout) >= 0) { 113 | $connectionConfig->setReadTimeout((float) $readTimeout); 114 | } 115 | 116 | $writeTimeout = Arr::get($config, self::CONFIG_OPTIONS.'.write_timeout'); 117 | if (is_numeric($writeTimeout) && floatval($writeTimeout) >= 0) { 118 | $connectionConfig->setWriteTimeout((float) $writeTimeout); 119 | } 120 | 121 | $chanelRpcTimeout = Arr::get($config, self::CONFIG_OPTIONS.'.channel_rpc_timeout'); 122 | if (is_numeric($chanelRpcTimeout) && floatval($chanelRpcTimeout) >= 0) { 123 | $connectionConfig->setChannelRPCTimeout((float) $chanelRpcTimeout); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Consumer.php: -------------------------------------------------------------------------------- 1 | container = $value; 41 | } 42 | 43 | public function setConsumerTag(string $value): void 44 | { 45 | $this->consumerTag = $value; 46 | } 47 | 48 | public function setMaxPriority(int $value): void 49 | { 50 | $this->maxPriority = $value; 51 | } 52 | 53 | public function setPrefetchSize(int $value): void 54 | { 55 | $this->prefetchSize = $value; 56 | } 57 | 58 | public function setPrefetchCount(int $value): void 59 | { 60 | $this->prefetchCount = $value; 61 | } 62 | 63 | /** 64 | * Listen to the given queue in a loop. 65 | * 66 | * @param string $connectionName 67 | * @param string $queue 68 | * @return int 69 | * 70 | * @throws Throwable 71 | */ 72 | public function daemon($connectionName, $queue, WorkerOptions $options) 73 | { 74 | if ($this->supportsAsyncSignals()) { 75 | $this->listenForSignals(); 76 | } 77 | 78 | $lastRestart = $this->getTimestampOfLastQueueRestart(); 79 | 80 | [$startTime, $jobsProcessed] = [hrtime(true) / 1e9, 0]; 81 | 82 | /** @var RabbitMQQueue $connection */ 83 | $connection = $this->manager->connection($connectionName); 84 | 85 | $this->channel = $connection->getChannel(); 86 | 87 | $this->channel->basic_qos( 88 | $this->prefetchSize, 89 | $this->prefetchCount, 90 | false 91 | ); 92 | 93 | $jobClass = $connection->getJobClass(); 94 | $arguments = []; 95 | if ($this->maxPriority) { 96 | $arguments['priority'] = ['I', $this->maxPriority]; 97 | } 98 | 99 | $this->channel->basic_consume( 100 | $queue, 101 | $this->consumerTag, 102 | false, 103 | false, 104 | false, 105 | false, 106 | function (AMQPMessage $message) use ($connection, $options, $connectionName, $queue, $jobClass, &$jobsProcessed): void { 107 | $job = new $jobClass( 108 | $this->container, 109 | $connection, 110 | $message, 111 | $connectionName, 112 | $queue 113 | ); 114 | 115 | $this->currentJob = $job; 116 | 117 | if ($this->supportsAsyncSignals()) { 118 | $this->registerTimeoutHandler($job, $options); 119 | } 120 | 121 | $jobsProcessed++; 122 | 123 | $this->runJob($job, $connectionName, $options); 124 | 125 | if ($this->supportsAsyncSignals()) { 126 | $this->resetTimeoutHandler(); 127 | } 128 | 129 | if ($options->rest > 0) { 130 | $this->sleep($options->rest); 131 | } 132 | }, 133 | null, 134 | $arguments 135 | ); 136 | 137 | while ($this->channel->is_consuming()) { 138 | // Before reserving any jobs, we will make sure this queue is not paused and 139 | // if it is we will just pause this worker for a given amount of time and 140 | // make sure we do not need to kill this worker process off completely. 141 | if (! $this->daemonShouldRun($options, $connectionName, $queue)) { 142 | $this->pauseWorker($options, $lastRestart); 143 | 144 | continue; 145 | } 146 | 147 | // If the daemon should run (not in maintenance mode, etc.), then we can wait for a job. 148 | try { 149 | $this->channel->wait(null, true, (int) $options->timeout); 150 | } catch (AMQPRuntimeException $exception) { 151 | $this->exceptions->report($exception); 152 | 153 | $this->kill(self::EXIT_ERROR, $options); 154 | } catch (Exception|Throwable $exception) { 155 | $this->exceptions->report($exception); 156 | 157 | $this->stopWorkerIfLostConnection($exception); 158 | } 159 | 160 | // If no job is got off the queue, we will need to sleep the worker. 161 | if ($this->currentJob === null) { 162 | $this->sleep($options->sleep); 163 | } 164 | 165 | // Finally, we will check to see if we have exceeded our memory limits or if 166 | // the queue should restart based on other indications. If so, we'll stop 167 | // this worker and let whatever is "monitoring" it restart the process. 168 | $status = $this->stopIfNecessary( 169 | $options, 170 | $lastRestart, 171 | $startTime, 172 | $jobsProcessed, 173 | $this->currentJob 174 | ); 175 | 176 | if (! is_null($status)) { 177 | return $this->stop($status, $options); 178 | } 179 | 180 | $this->currentJob = null; 181 | } 182 | } 183 | 184 | /** 185 | * Determine if the daemon should process on this iteration. 186 | * 187 | * @param string $connectionName 188 | * @param string $queue 189 | */ 190 | protected function daemonShouldRun(WorkerOptions $options, $connectionName, $queue): bool 191 | { 192 | return ! ((($this->isDownForMaintenance)() && ! $options->force) || $this->paused); 193 | } 194 | 195 | /** 196 | * Stop listening and bail out of the script. 197 | * 198 | * @param int $status 199 | * @param WorkerOptions|null $options 200 | * @return int 201 | */ 202 | public function stop($status = 0, $options = null) 203 | { 204 | // Tell the server you are going to stop consuming. 205 | // It will finish up the last message and not send you any more. 206 | $this->channel->basic_cancel($this->consumerTag, false, true); 207 | 208 | return parent::stop($status, $options); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Queue/QueueConfig.php: -------------------------------------------------------------------------------- 1 | queue; 44 | } 45 | 46 | public function setQueue(string $queue): QueueConfig 47 | { 48 | $this->queue = $queue; 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * Returns &true; as indication that jobs should be dispatched after all database transactions 55 | * have been committed. 56 | */ 57 | public function isDispatchAfterCommit(): bool 58 | { 59 | return $this->dispatchAfterCommit; 60 | } 61 | 62 | public function setDispatchAfterCommit($dispatchAfterCommit): QueueConfig 63 | { 64 | $this->dispatchAfterCommit = $this->toBoolean($dispatchAfterCommit); 65 | 66 | return $this; 67 | } 68 | 69 | /** 70 | * Get the Job::class to use when processing messages 71 | */ 72 | public function getAbstractJob(): string 73 | { 74 | return $this->abstractJob; 75 | } 76 | 77 | public function setAbstractJob(string $abstract): QueueConfig 78 | { 79 | $this->abstractJob = $abstract; 80 | 81 | return $this; 82 | } 83 | 84 | /** 85 | * Returns &true;, if delayed messages should be prioritized. 86 | * 87 | * RabbitMQ queues work with the FIFO method. So when there are 10000 messages in the queue and 88 | * the delayed message is put back to the queue (at the end) for further processing the delayed message won´t 89 | * process before all 10000 messages are processed. The same is true for requeueing. 90 | * 91 | * This may not what you desire. 92 | * When you want the message to get processed immediately after the delayed time expires or when requeueing, we can 93 | * use prioritization. 94 | * 95 | * @see[https://www.rabbitmq.com/queues.html#basics] 96 | */ 97 | public function isPrioritizeDelayed(): bool 98 | { 99 | return $this->prioritizeDelayed; 100 | } 101 | 102 | public function setPrioritizeDelayed($prioritizeDelayed): QueueConfig 103 | { 104 | $this->prioritizeDelayed = $this->toBoolean($prioritizeDelayed); 105 | 106 | return $this; 107 | } 108 | 109 | /** 110 | * Returns a integer with a default of '2' for when using prioritization on delayed messages. 111 | * If priority queues are desired, we recommend using between 1 and 10. 112 | * Using more priority layers, will consume more CPU resources and would affect runtimes. 113 | * 114 | * @see https://www.rabbitmq.com/priority.html 115 | */ 116 | public function getQueueMaxPriority(): int 117 | { 118 | return $this->queueMaxPriority; 119 | } 120 | 121 | public function setQueueMaxPriority($queueMaxPriority): QueueConfig 122 | { 123 | if (is_numeric($queueMaxPriority) && intval($queueMaxPriority) > 1) { 124 | $this->queueMaxPriority = (int) $queueMaxPriority; 125 | } 126 | 127 | return $this; 128 | } 129 | 130 | /** 131 | * Get the exchange name, or empty string; as default value. 132 | * 133 | * The default exchange is an unnamed pre-declared direct exchange. Usually, an empty string 134 | * is frequently used to indicate it. If you choose default exchange, your message will be delivered 135 | * to a queue with the same name as the routing key. 136 | * With a routing key that is the same as the queue name, every queue is immediately tied to the default exchange. 137 | */ 138 | public function getExchange(): string 139 | { 140 | return $this->exchange; 141 | } 142 | 143 | public function setExchange(string $exchange): QueueConfig 144 | { 145 | $this->exchange = $exchange; 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * Get the exchange type 152 | * 153 | * There are four basic RabbitMQ exchange types in RabbitMQ, each of which uses different parameters 154 | * and bindings to route messages in various ways, These are: 'direct', 'topic', 'fanout', 'headers' 155 | * 156 | * The default type is set as 'direct' 157 | */ 158 | public function getExchangeType(): string 159 | { 160 | return $this->exchangeType; 161 | } 162 | 163 | public function setExchangeType(string $exchangeType): QueueConfig 164 | { 165 | $this->exchangeType = $exchangeType; 166 | 167 | return $this; 168 | } 169 | 170 | /** 171 | * Get the routing key when using an exchange other than the direct exchange. 172 | * The routing key is a message attribute taken into account by the exchange when deciding how to route a message. 173 | * 174 | * The default routing-key is the given destination: '%s'. 175 | */ 176 | public function getExchangeRoutingKey(): string 177 | { 178 | return $this->exchangeRoutingKey; 179 | } 180 | 181 | public function setExchangeRoutingKey(string $exchangeRoutingKey): QueueConfig 182 | { 183 | $this->exchangeRoutingKey = $exchangeRoutingKey; 184 | 185 | return $this; 186 | } 187 | 188 | /** 189 | * Returns &true;, if failed messages should be rerouted. 190 | */ 191 | public function isRerouteFailed(): bool 192 | { 193 | return $this->rerouteFailed; 194 | } 195 | 196 | public function setRerouteFailed($rerouteFailed): QueueConfig 197 | { 198 | $this->rerouteFailed = $this->toBoolean($rerouteFailed); 199 | 200 | return $this; 201 | } 202 | 203 | /** 204 | * Get the exchange name with messages are published against. 205 | * The default exchange is empty, so messages will be published directly to a queue. 206 | */ 207 | public function getFailedExchange(): string 208 | { 209 | return $this->failedExchange; 210 | } 211 | 212 | public function setFailedExchange(string $failedExchange): QueueConfig 213 | { 214 | $this->failedExchange = $failedExchange; 215 | 216 | return $this; 217 | } 218 | 219 | /** 220 | * Get the substitution string for failed messages 221 | * The default routing-key is the given destination substituted by '%s.failed'. 222 | */ 223 | public function getFailedRoutingKey(): string 224 | { 225 | return $this->failedRoutingKey; 226 | } 227 | 228 | public function setFailedRoutingKey(string $failedRoutingKey): QueueConfig 229 | { 230 | $this->failedRoutingKey = $failedRoutingKey; 231 | 232 | return $this; 233 | } 234 | 235 | /** 236 | * Returns &true;, if queue is marked or set as quorum queue. 237 | */ 238 | public function isQuorum(): bool 239 | { 240 | return $this->quorum; 241 | } 242 | 243 | public function setQuorum($quorum): QueueConfig 244 | { 245 | $this->quorum = $this->toBoolean($quorum); 246 | 247 | return $this; 248 | } 249 | 250 | /** 251 | * Holds all unknown queue options provided in the connection config 252 | */ 253 | public function getOptions(): array 254 | { 255 | return $this->options; 256 | } 257 | 258 | public function setOptions(array $options): QueueConfig 259 | { 260 | $this->options = $options; 261 | 262 | return $this; 263 | } 264 | 265 | /** 266 | * Filters $value to boolean value 267 | * 268 | * Returns: &true; 269 | * For values: 1, '1', true, 'true', 'yes' 270 | * 271 | * Returns: &false; 272 | * For values: 0, '0', false, 'false', '', null, [] , 'ok', 'no', 'no not a bool', 'yes a bool' 273 | */ 274 | protected function toBoolean($value): bool 275 | { 276 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/Queue/Connection/ConnectionFactory.php: -------------------------------------------------------------------------------- 1 | getIoType() === AMQPConnectionConfig::IO_TYPE_SOCKET) { 76 | return self::createSocketConnection($connection, $config); 77 | } 78 | 79 | return self::createStreamConnection($connection, $config); 80 | } 81 | 82 | protected static function createSocketConnection($connection, AMQPConnectionConfig $config): AMQPSocketConnection 83 | { 84 | self::assertSocketConnection($connection, $config); 85 | 86 | return new $connection( 87 | $config->getHost(), 88 | $config->getPort(), 89 | $config->getUser(), 90 | $config->getPassword(), 91 | $config->getVhost(), 92 | $config->isInsist(), 93 | $config->getLoginMethod(), 94 | $config->getLoginResponse(), 95 | $config->getLocale(), 96 | $config->getReadTimeout(), 97 | $config->isKeepalive(), 98 | $config->getWriteTimeout(), 99 | $config->getHeartbeat(), 100 | $config->getChannelRPCTimeout(), 101 | $config 102 | ); 103 | } 104 | 105 | protected static function createStreamConnection($connection, AMQPConnectionConfig $config): AMQPStreamConnection 106 | { 107 | self::assertStreamConnection($connection); 108 | 109 | if ($config->isSecure()) { 110 | self::assertSSLConnection($connection); 111 | 112 | return new $connection( 113 | $config->getHost(), 114 | $config->getPort(), 115 | $config->getUser(), 116 | $config->getPassword(), 117 | $config->getVhost(), 118 | self::getSslOptions($config), 119 | [ 120 | 'insist' => $config->isInsist(), 121 | 'login_method' => $config->getLoginMethod(), 122 | 'login_response' => $config->getLoginResponse(), 123 | 'locale' => $config->getLocale(), 124 | 'connection_timeout' => $config->getConnectionTimeout(), 125 | 'read_write_timeout' => self::getReadWriteTimeout($config), 126 | 'keepalive' => $config->isKeepalive(), 127 | 'heartbeat' => $config->getHeartbeat(), 128 | ], 129 | $config 130 | ); 131 | } 132 | 133 | return new $connection( 134 | $config->getHost(), 135 | $config->getPort(), 136 | $config->getUser(), 137 | $config->getPassword(), 138 | $config->getVhost(), 139 | $config->isInsist(), 140 | $config->getLoginMethod(), 141 | $config->getLoginResponse(), 142 | $config->getLocale(), 143 | $config->getConnectionTimeout(), 144 | self::getReadWriteTimeout($config), 145 | $config->getStreamContext(), 146 | $config->isKeepalive(), 147 | $config->getHeartbeat(), 148 | $config->getChannelRPCTimeout(), 149 | $config->getNetworkProtocol(), 150 | $config 151 | ); 152 | } 153 | 154 | protected static function getReadWriteTimeout(AMQPConnectionConfig $config): float 155 | { 156 | return min($config->getReadTimeout(), $config->getWriteTimeout()); 157 | } 158 | 159 | protected static function getSslOptions(AMQPConnectionConfig $config): array 160 | { 161 | return array_filter([ 162 | 'cafile' => $config->getSslCaCert(), 163 | 'capath' => $config->getSslCaPath(), 164 | 'local_cert' => $config->getSslCert(), 165 | 'local_pk' => $config->getSslKey(), 166 | 'verify_peer' => $config->getSslVerify(), 167 | 'verify_peer_name' => $config->getSslVerifyName(), 168 | 'passphrase' => $config->getSslPassPhrase(), 169 | 'ciphers' => $config->getSslCiphers(), 170 | 'security_level' => $config->getSslSecurityLevel(), 171 | ], static function ($value) { 172 | return $value !== null; 173 | }); 174 | } 175 | 176 | protected static function assertConnectionFromConfig(string $connection): void 177 | { 178 | if ($connection !== self::CONNECTION_TYPE_DEFAULT && ! is_subclass_of($connection, self::CONNECTION_TYPE_EXTENDED)) { 179 | throw new AMQPLogicException(sprintf('The config property \'%s\' must contain \'%s\' or must extend: %s', self::CONFIG_CONNECTION, self::CONNECTION_TYPE_DEFAULT, class_basename(self::CONNECTION_TYPE_EXTENDED))); 180 | } 181 | } 182 | 183 | protected static function assertSocketConnection($connection, AMQPConnectionConfig $config): void 184 | { 185 | self::assertExtendedOf($connection, self::CONNECTION_SUB_TYPE_SOCKET); 186 | 187 | if ($config->isSecure()) { 188 | throw new AMQPLogicException('The socket connection implementation does not support secure connections.'); 189 | } 190 | } 191 | 192 | protected static function assertStreamConnection($connection): void 193 | { 194 | self::assertExtendedOf($connection, self::CONNECTION_SUB_TYPE_STREAM); 195 | } 196 | 197 | protected static function assertSSLConnection($connection): void 198 | { 199 | self::assertExtendedOf($connection, self::CONNECTION_SUB_TYPE_SSL); 200 | } 201 | 202 | protected static function assertExtendedOf($connection, string $parent): void 203 | { 204 | if (! is_subclass_of($connection, $parent) && $connection !== $parent) { 205 | throw new AMQPLogicException(sprintf('The connection must extend: %s', class_basename($parent))); 206 | } 207 | } 208 | 209 | /** 210 | * @return mixed 211 | * 212 | * @throws Exception 213 | * 214 | * @deprecated This is the fallback method, update your config asap. (example: connection => 'default') 215 | */ 216 | protected static function _createLazyConnection($connection, array $config): AbstractConnection 217 | { 218 | return $connection::create_connection( 219 | Arr::shuffle(Arr::get($config, ConfigFactory::CONFIG_HOSTS, [])), 220 | Arr::add(Arr::get($config, 'options', []), 'heartbeat', 0) 221 | ); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | RabbitMQ Queue driver for Laravel 2 | ====================== 3 | [![Latest Stable Version](https://poser.pugx.org/vladimir-yuldashev/laravel-queue-rabbitmq/v/stable?format=flat-square)](https://packagist.org/packages/vladimir-yuldashev/laravel-queue-rabbitmq) 4 | [![Build Status](https://github.com/vyuldashev/laravel-queue-rabbitmq/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/vyuldashev/laravel-queue-rabbitmq/actions/workflows/tests.yml) 5 | [![Total Downloads](https://poser.pugx.org/vladimir-yuldashev/laravel-queue-rabbitmq/downloads?format=flat-square)](https://packagist.org/packages/vladimir-yuldashev/laravel-queue-rabbitmq) 6 | [![License](https://poser.pugx.org/vladimir-yuldashev/laravel-queue-rabbitmq/license?format=flat-square)](https://packagist.org/packages/vladimir-yuldashev/laravel-queue-rabbitmq) 7 | 8 | ## Support Policy 9 | 10 | Only the latest version will get new features. Bug fixes will be provided using the following scheme: 11 | 12 | | Package Version | Laravel Version | Bug Fixes Until | | 13 | |-----------------|-----------------|------------------|---------------------------------------------------------------------------------------------| 14 | | 13 | 9 | August 8th, 2023 | [Documentation](https://github.com/vyuldashev/laravel-queue-rabbitmq/blob/master/README.md) | 15 | 16 | ## Installation 17 | 18 | You can install this package via composer using this command: 19 | 20 | ``` 21 | composer require vladimir-yuldashev/laravel-queue-rabbitmq 22 | ``` 23 | 24 | The package will automatically register itself. 25 | 26 | ### Configuration 27 | 28 | Add connection to `config/queue.php`: 29 | 30 | > This is the minimal config for the rabbitMQ connection/driver to work. 31 | 32 | ```php 33 | 'connections' => [ 34 | // ... 35 | 36 | 'rabbitmq' => [ 37 | 38 | 'driver' => 'rabbitmq', 39 | 'hosts' => [ 40 | [ 41 | 'host' => env('RABBITMQ_HOST', '127.0.0.1'), 42 | 'port' => env('RABBITMQ_PORT', 5672), 43 | 'user' => env('RABBITMQ_USER', 'guest'), 44 | 'password' => env('RABBITMQ_PASSWORD', 'guest'), 45 | 'vhost' => env('RABBITMQ_VHOST', '/'), 46 | ], 47 | // ... 48 | ], 49 | 50 | // ... 51 | ], 52 | 53 | // ... 54 | ], 55 | ``` 56 | 57 | ### Optional Queue Config 58 | 59 | Optionally add queue options to the config of a connection. 60 | Every queue created for this connection, gets the properties. 61 | 62 | When you want to prioritize messages when they were delayed, then this is possible by adding extra options. 63 | 64 | - When max-priority is omitted, the max priority is set with 2 when used. 65 | 66 | ```php 67 | 'connections' => [ 68 | // ... 69 | 70 | 'rabbitmq' => [ 71 | // ... 72 | 73 | 'options' => [ 74 | 'queue' => [ 75 | // ... 76 | 77 | 'prioritize_delayed' => false, 78 | 'queue_max_priority' => 10, 79 | ], 80 | ], 81 | ], 82 | 83 | // ... 84 | ], 85 | ``` 86 | 87 | When you want to publish messages against an exchange with routing-keys, then this is possible by adding extra options. 88 | 89 | - When the exchange is omitted, RabbitMQ will use the `amq.direct` exchange for the routing-key 90 | - When routing-key is omitted the routing-key by default is the `queue` name. 91 | - When using `%s` in the routing-key the queue_name will be substituted. 92 | 93 | > Note: when using an exchange with routing-key, you probably create your queues with bindings yourself. 94 | 95 | ```php 96 | 'connections' => [ 97 | // ... 98 | 99 | 'rabbitmq' => [ 100 | // ... 101 | 102 | 'options' => [ 103 | 'queue' => [ 104 | // ... 105 | 106 | 'exchange' => 'application-x', 107 | 'exchange_type' => 'topic', 108 | 'exchange_routing_key' => '', 109 | ], 110 | ], 111 | ], 112 | 113 | // ... 114 | ], 115 | ``` 116 | 117 | In Laravel failed jobs are stored into the database. But maybe you want to instruct some other process to also do 118 | something with the message. 119 | When you want to instruct RabbitMQ to reroute failed messages to a exchange or a specific queue, then this is possible 120 | by adding extra options. 121 | 122 | - When the exchange is omitted, RabbitMQ will use the `amq.direct` exchange for the routing-key 123 | - When routing-key is omitted, the routing-key by default the `queue` name is substituted with `'.failed'`. 124 | - When using `%s` in the routing-key the queue_name will be substituted. 125 | 126 | > Note: When using failed_job exchange with routing-key, you probably need to create your exchange/queue with bindings 127 | > yourself. 128 | 129 | ```php 130 | 'connections' => [ 131 | // ... 132 | 133 | 'rabbitmq' => [ 134 | // ... 135 | 136 | 'options' => [ 137 | 'queue' => [ 138 | // ... 139 | 140 | 'reroute_failed' => true, 141 | 'failed_exchange' => 'failed-exchange', 142 | 'failed_routing_key' => 'application-x.%s', 143 | ], 144 | ], 145 | ], 146 | 147 | // ... 148 | ], 149 | ``` 150 | 151 | ### Horizon support 152 | 153 | Starting with 8.0, this package supports [Laravel Horizon](https://laravel.com/docs/horizon) out of the box. Firstly, 154 | install Horizon and then set `RABBITMQ_WORKER` to `horizon`. 155 | 156 | Horizon is depending on events dispatched by the worker. 157 | These events inform Horizon what was done with the message/job. 158 | 159 | This Library supports Horizon, but in the config you have to inform Laravel to use the QueueApi compatible with horizon. 160 | 161 | ```php 162 | 'connections' => [ 163 | // ... 164 | 165 | 'rabbitmq' => [ 166 | // ... 167 | 168 | /* Set to "horizon" if you wish to use Laravel Horizon. */ 169 | 'worker' => env('RABBITMQ_WORKER', 'default'), 170 | ], 171 | 172 | // ... 173 | ], 174 | ``` 175 | 176 | ### Use your own RabbitMQJob class 177 | 178 | Sometimes you have to work with messages published by another application. 179 | Those messages probably won't respect Laravel's job payload schema. 180 | The problem with these messages is that, Laravel workers won't be able to determine the actual job or class to execute. 181 | 182 | You can extend the build-in `RabbitMQJob::class` and within the queue connection config, you can define your own class. 183 | When you specify a `job` key in the config, with your own class name, every message retrieved from the broker will get 184 | wrapped by your own class. 185 | 186 | An example for the config: 187 | 188 | ```php 189 | 'connections' => [ 190 | // ... 191 | 192 | 'rabbitmq' => [ 193 | // ... 194 | 195 | 'options' => [ 196 | 'queue' => [ 197 | // ... 198 | 199 | 'job' => \App\Queue\Jobs\RabbitMQJob::class, 200 | ], 201 | ], 202 | ], 203 | 204 | // ... 205 | ], 206 | ``` 207 | 208 | An example of your own job class: 209 | 210 | ```php 211 | payload(); 228 | 229 | $class = WhatheverClassNameToExecute::class; 230 | $method = 'handle'; 231 | 232 | ($this->instance = $this->resolve($class))->{$method}($this, $payload); 233 | 234 | $this->delete(); 235 | } 236 | } 237 | 238 | ``` 239 | 240 | Or maybe you want to add extra properties to the payload: 241 | 242 | ```php 243 | 'WhatheverFullyQualifiedClassNameToExecute@handle', 260 | 'data' => json_decode($this->getRawBody(), true) 261 | ]; 262 | } 263 | } 264 | ``` 265 | 266 | If you want to handle raw message, not in JSON format or without 'job' key in JSON, 267 | you should add stub for `getName` method: 268 | 269 | ```php 270 | getRawBody(); 282 | Log::info($anyMessage); 283 | 284 | $this->delete(); 285 | } 286 | 287 | public function getName() 288 | { 289 | return ''; 290 | } 291 | } 292 | ``` 293 | 294 | ### Use your own Connection 295 | 296 | You can extend the built-in `PhpAmqpLib\Connection\AMQPStreamConnection::class` 297 | or `PhpAmqpLib\Connection\AMQPSLLConnection::class` and within the connection config, you can define your own class. 298 | When you specify a `connection` key in the config, with your own class name, every connection will use your own class. 299 | 300 | An example for the config: 301 | 302 | ```php 303 | 'connections' => [ 304 | // ... 305 | 306 | 'rabbitmq' => [ 307 | // ... 308 | 309 | 'connection' = > \App\Queue\Connection\MyRabbitMQConnection::class, 310 | ], 311 | 312 | // ... 313 | ], 314 | ``` 315 | 316 | ### Use your own Worker class 317 | 318 | If you want to use your own `RabbitMQQueue::class` this is possible by 319 | extending `VladimirYuldashev\LaravelQueueRabbitMQ\Queue\RabbitMQQueue`. 320 | and inform laravel to use your class by setting `RABBITMQ_WORKER` to `\App\Queue\RabbitMQQueue::class`. 321 | 322 | > Note: Worker classes **must** extend `VladimirYuldashev\LaravelQueueRabbitMQ\Queue\RabbitMQQueue` 323 | 324 | ```php 325 | 'connections' => [ 326 | // ... 327 | 328 | 'rabbitmq' => [ 329 | // ... 330 | 331 | /* Set to a class if you wish to use your own. */ 332 | 'worker' => \App\Queue\RabbitMQQueue::class, 333 | ], 334 | 335 | // ... 336 | ], 337 | ``` 338 | 339 | ```php 340 | Note: this is not best practice, it is an example. 358 | 359 | ```php 360 | reconnect(); 377 | parent::publishBasic($msg, $exchange, $destination, $mandatory, $immediate, $ticket); 378 | } 379 | } 380 | 381 | protected function publishBatch($jobs, $data = '', $queue = null): void 382 | { 383 | try { 384 | parent::publishBatch($jobs, $data, $queue); 385 | } catch (AMQPConnectionClosedException|AMQPChannelClosedException) { 386 | $this->reconnect(); 387 | parent::publishBatch($jobs, $data, $queue); 388 | } 389 | } 390 | 391 | protected function createChannel(): AMQPChannel 392 | { 393 | try { 394 | return parent::createChannel(); 395 | } catch (AMQPConnectionClosedException) { 396 | $this->reconnect(); 397 | return parent::createChannel(); 398 | } 399 | } 400 | } 401 | ``` 402 | 403 | ### Default Queue 404 | 405 | The connection does use a default queue with value 'default', when no queue is provided by laravel. 406 | It is possible to change te default queue by adding an extra parameter in the connection config. 407 | 408 | ```php 409 | 'connections' => [ 410 | // ... 411 | 412 | 'rabbitmq' => [ 413 | // ... 414 | 415 | 'queue' => env('RABBITMQ_QUEUE', 'default'), 416 | ], 417 | 418 | // ... 419 | ], 420 | ``` 421 | 422 | ### Heartbeat 423 | 424 | By default, your connection will be created with a heartbeat setting of `0`. 425 | You can alter the heartbeat settings by changing the config. 426 | 427 | ```php 428 | 429 | 'connections' => [ 430 | // ... 431 | 432 | 'rabbitmq' => [ 433 | // ... 434 | 435 | 'options' => [ 436 | // ... 437 | 438 | 'heartbeat' => 10, 439 | ], 440 | ], 441 | 442 | // ... 443 | ], 444 | ``` 445 | 446 | ### SSL Secure 447 | 448 | If you need a secure connection to rabbitMQ server(s), you will need to add these extra config options. 449 | 450 | ```php 451 | 'connections' => [ 452 | // ... 453 | 454 | 'rabbitmq' => [ 455 | // ... 456 | 457 | 'secure' = > true, 458 | 'options' => [ 459 | // ... 460 | 461 | 'ssl_options' => [ 462 | 'cafile' => env('RABBITMQ_SSL_CAFILE', null), 463 | 'local_cert' => env('RABBITMQ_SSL_LOCALCERT', null), 464 | 'local_key' => env('RABBITMQ_SSL_LOCALKEY', null), 465 | 'verify_peer' => env('RABBITMQ_SSL_VERIFY_PEER', true), 466 | 'passphrase' => env('RABBITMQ_SSL_PASSPHRASE', null), 467 | ], 468 | ], 469 | ], 470 | 471 | // ... 472 | ], 473 | ``` 474 | 475 | ### Events after Database commits 476 | 477 | To instruct Laravel workers to dispatch events after all database commits are completed. 478 | 479 | ```php 480 | 'connections' => [ 481 | // ... 482 | 483 | 'rabbitmq' => [ 484 | // ... 485 | 486 | 'after_commit' => true, 487 | ], 488 | 489 | // ... 490 | ], 491 | ``` 492 | 493 | ### Lazy Connection 494 | 495 | By default, your connection will be created as a lazy connection. 496 | If for some reason you don't want the connection lazy you can turn it off by setting the following config. 497 | 498 | ```php 499 | 'connections' => [ 500 | // ... 501 | 502 | 'rabbitmq' => [ 503 | // ... 504 | 505 | 'lazy' = > false, 506 | ], 507 | 508 | // ... 509 | ], 510 | ``` 511 | 512 | ### Network Protocol 513 | 514 | By default, the network protocol used for connection is tcp. 515 | If for some reason you want to use another network protocol, you can add the extra value in your config options. 516 | Available protocols : `tcp`, `ssl`, `tls` 517 | 518 | ```php 519 | 'connections' => [ 520 | // ... 521 | 522 | 'rabbitmq' => [ 523 | // ... 524 | 525 | 'network_protocol' => 'tcp', 526 | ], 527 | 528 | // ... 529 | ], 530 | ``` 531 | 532 | ### Network Timeouts 533 | 534 | For network timeouts configuration you can use option parameters. 535 | All float values are in seconds and zero value can mean infinite timeout. 536 | Example contains default values. 537 | 538 | ```php 539 | 'connections' => [ 540 | // ... 541 | 542 | 'rabbitmq' => [ 543 | // ... 544 | 545 | 'options' => [ 546 | // ... 547 | 548 | 'connection_timeout' => 3.0, 549 | 'read_timeout' => 3.0, 550 | 'write_timeout' => 3.0, 551 | 'channel_rpc_timeout' => 0.0, 552 | ], 553 | ], 554 | 555 | // ... 556 | ], 557 | ``` 558 | 559 | ### Octane support 560 | 561 | Starting with 13.3.0, this package supports [Laravel Octane](https://laravel.com/docs/octane) out of the box. 562 | Firstly, install Octane and don't forget to warm 'rabbitmq' connection in the octane config. 563 | > See: https://github.com/vyuldashev/laravel-queue-rabbitmq/issues/460#issuecomment-1469851667 564 | 565 | ## Laravel Usage 566 | 567 | Once you completed the configuration you can use the Laravel Queue API. If you used other queue drivers you do not 568 | need to change anything else. If you do not know how to use the Queue API, please refer to the official Laravel 569 | documentation: http://laravel.com/docs/queues 570 | 571 | ## Lumen Usage 572 | 573 | For Lumen usage the service provider should be registered manually as follow in `bootstrap/app.php`: 574 | 575 | ```php 576 | $app->register(VladimirYuldashev\LaravelQueueRabbitMQ\LaravelQueueRabbitMQServiceProvider::class); 577 | ``` 578 | 579 | ## Consuming Messages 580 | 581 | There are two ways of consuming messages. 582 | 583 | 1. `queue:work` command which is Laravel's built-in command. This command utilizes `basic_get`. Use this if you want to consume multiple queues. 584 | 585 | 2. `rabbitmq:consume` command which is provided by this package. This command utilizes `basic_consume` and is more performant than `basic_get` by ~2x, but does not support multiple queues. 586 | 587 | ## Testing 588 | 589 | Setup RabbitMQ using `docker-compose`: 590 | 591 | ```bash 592 | docker compose up -d 593 | ``` 594 | 595 | To run the test suite you can use the following commands: 596 | 597 | ```bash 598 | # To run both style and unit tests. 599 | composer test 600 | 601 | # To run only style tests. 602 | composer test:style 603 | 604 | # To run only unit tests. 605 | composer test:unit 606 | ``` 607 | 608 | If you receive any errors from the style tests, you can automatically fix most, 609 | if not all the issues with the following command: 610 | 611 | ```bash 612 | composer fix:style 613 | ``` 614 | 615 | ## Contribution 616 | 617 | You can contribute to this package by discovering bugs and opening issues. Please, add to which version of package you 618 | create pull request or issue. (e.g. [5.2] Fatal error on delayed job) 619 | -------------------------------------------------------------------------------- /src/Queue/RabbitMQQueue.php: -------------------------------------------------------------------------------- 1 | rabbitMQConfig = $config; 73 | $this->dispatchAfterCommit = $config->isDispatchAfterCommit(); 74 | } 75 | 76 | /** 77 | * {@inheritdoc} 78 | * 79 | * @throws AMQPProtocolChannelException 80 | */ 81 | public function size($queue = null): int 82 | { 83 | $queue = $this->getQueue($queue); 84 | 85 | if (! $this->isQueueExists($queue)) { 86 | return 0; 87 | } 88 | 89 | // create a temporary channel, so the main channel will not be closed on exception 90 | $channel = $this->createChannel(); 91 | [, $size] = $channel->queue_declare($queue, true); 92 | $channel->close(); 93 | 94 | return $size; 95 | } 96 | 97 | /** 98 | * {@inheritdoc} 99 | * 100 | * @throws AMQPProtocolChannelException 101 | */ 102 | public function push($job, $data = '', $queue = null) 103 | { 104 | return $this->enqueueUsing( 105 | $job, 106 | $this->createPayload($job, $this->getQueue($queue), $data), 107 | $queue, 108 | null, 109 | function ($payload, $queue) { 110 | return $this->pushRaw($payload, $queue); 111 | } 112 | ); 113 | } 114 | 115 | /** 116 | * {@inheritdoc} 117 | * 118 | * @throws AMQPProtocolChannelException 119 | */ 120 | public function pushRaw($payload, $queue = null, array $options = []): int|string|null 121 | { 122 | [$destination, $exchange, $exchangeType, $attempts] = $this->publishProperties($queue, $options); 123 | 124 | $this->declareDestination($destination, $exchange, $exchangeType); 125 | 126 | [$message, $correlationId] = $this->createMessage($payload, $attempts); 127 | 128 | $this->publishBasic($message, $exchange, $destination, true); 129 | 130 | return $correlationId; 131 | } 132 | 133 | /** 134 | * {@inheritdoc} 135 | * 136 | * @throws AMQPProtocolChannelException 137 | */ 138 | public function later($delay, $job, $data = '', $queue = null): mixed 139 | { 140 | return $this->enqueueUsing( 141 | $job, 142 | $this->createPayload($job, $this->getQueue($queue), $data), 143 | $queue, 144 | $delay, 145 | function ($payload, $queue, $delay) { 146 | return $this->laterRaw($delay, $payload, $queue); 147 | } 148 | ); 149 | } 150 | 151 | /** 152 | * @throws AMQPProtocolChannelException 153 | */ 154 | public function laterRaw($delay, string $payload, $queue = null, int $attempts = 0): int|string|null 155 | { 156 | $ttl = $this->secondsUntil($delay) * 1000; 157 | 158 | // default options 159 | $options = ['delay' => $delay, 'attempts' => $attempts]; 160 | 161 | // When no ttl just publish a new message to the exchange or queue 162 | if ($ttl <= 0) { 163 | return $this->pushRaw($payload, $queue, $options); 164 | } 165 | 166 | // Create a main queue to handle delayed messages 167 | [$mainDestination, $exchange, $exchangeType, $attempts] = $this->publishProperties($queue, $options); 168 | $this->declareDestination($mainDestination, $exchange, $exchangeType); 169 | 170 | $destination = $this->getQueue($queue).'.delay.'.$ttl; 171 | 172 | $this->declareQueue($destination, true, false, $this->getDelayQueueArguments($this->getQueue($queue), $ttl)); 173 | 174 | [$message, $correlationId] = $this->createMessage($payload, $attempts); 175 | 176 | // Publish directly on the delayQueue, no need to publish through an exchange. 177 | $this->publishBasic($message, null, $destination, true); 178 | 179 | return $correlationId; 180 | } 181 | 182 | /** 183 | * {@inheritdoc} 184 | * 185 | * @throws AMQPProtocolChannelException 186 | */ 187 | public function bulk($jobs, $data = '', $queue = null): void 188 | { 189 | $this->publishBatch($jobs, $data, $queue); 190 | } 191 | 192 | /** 193 | * @throws AMQPProtocolChannelException 194 | */ 195 | protected function publishBatch($jobs, $data = '', $queue = null): void 196 | { 197 | foreach ($jobs as $job) { 198 | $this->bulkRaw($this->createPayload($job, $queue, $data), $queue, ['job' => $job]); 199 | } 200 | 201 | $this->batchPublish(); 202 | } 203 | 204 | /** 205 | * @throws AMQPProtocolChannelException 206 | */ 207 | public function bulkRaw(string $payload, ?string $queue = null, array $options = []): int|string|null 208 | { 209 | [$destination, $exchange, $exchangeType, $attempts] = $this->publishProperties($queue, $options); 210 | 211 | $this->declareDestination($destination, $exchange, $exchangeType); 212 | 213 | [$message, $correlationId] = $this->createMessage($payload, $attempts); 214 | 215 | $this->getChannel()->batch_basic_publish($message, $exchange, $destination); 216 | 217 | return $correlationId; 218 | } 219 | 220 | /** 221 | * {@inheritdoc} 222 | * 223 | * @throws Throwable 224 | */ 225 | public function pop($queue = null) 226 | { 227 | try { 228 | $queue = $this->getQueue($queue); 229 | 230 | $job = $this->getJobClass(); 231 | 232 | /** @var AMQPMessage|null $message */ 233 | if ($message = $this->getChannel()->basic_get($queue)) { 234 | return $this->currentJob = new $job( 235 | $this->container, 236 | $this, 237 | $message, 238 | $this->connectionName, 239 | $queue 240 | ); 241 | } 242 | } catch (AMQPProtocolChannelException $exception) { 243 | // If there is no exchange or queue AMQP will throw exception with code 404 244 | // We need to catch it and return null 245 | if ($exception->amqp_reply_code === 404) { 246 | // Because of the channel exception the channel was closed and removed. 247 | // We have to open a new channel. Because else the worker(s) are stuck in a loop, without processing. 248 | $this->getChannel(true); 249 | 250 | return null; 251 | } 252 | 253 | throw $exception; 254 | } catch (AMQPChannelClosedException|AMQPConnectionClosedException $exception) { 255 | // Queue::pop used by worker to receive new job 256 | // Thrown exception is checked by Illuminate\Database\DetectsLostConnections::causedByLostConnection 257 | // Is has to contain one of the several phrases in exception message in order to restart worker 258 | // Otherwise worker continues to work with broken connection 259 | throw new AMQPRuntimeException( 260 | 'Lost connection: '.$exception->getMessage(), 261 | $exception->getCode(), 262 | $exception 263 | ); 264 | } 265 | 266 | return null; 267 | } 268 | 269 | /** 270 | * @throws RuntimeException 271 | */ 272 | public function getConnection(): AbstractConnection 273 | { 274 | if (! $this->connection) { 275 | throw new RuntimeException('Queue has no AMQPConnection set.'); 276 | } 277 | 278 | return $this->connection; 279 | } 280 | 281 | public function setConnection(AbstractConnection $connection): RabbitMQQueue 282 | { 283 | $this->connection = $connection; 284 | 285 | return $this; 286 | } 287 | 288 | /** 289 | * Job class to use. 290 | * 291 | * 292 | * @throws Throwable 293 | */ 294 | public function getJobClass(): string 295 | { 296 | $job = $this->getRabbitMQConfig()->getAbstractJob(); 297 | 298 | throw_if( 299 | ! is_a($job, RabbitMQJob::class, true), 300 | Exception::class, 301 | sprintf('Class %s must extend: %s', $job, RabbitMQJob::class) 302 | ); 303 | 304 | return $job; 305 | } 306 | 307 | /** 308 | * Gets a queue/destination, by default the queue option set on the connection. 309 | */ 310 | public function getQueue($queue = null): string 311 | { 312 | return $queue ?: $this->getRabbitMQConfig()->getQueue(); 313 | } 314 | 315 | /** 316 | * Checks if the given exchange already present/defined in RabbitMQ. 317 | * Returns false when the exchange is missing. 318 | * 319 | * 320 | * @throws AMQPProtocolChannelException 321 | */ 322 | public function isExchangeExists(string $exchange): bool 323 | { 324 | if ($this->isExchangeDeclared($exchange)) { 325 | return true; 326 | } 327 | 328 | try { 329 | // create a temporary channel, so the main channel will not be closed on exception 330 | $channel = $this->createChannel(); 331 | $channel->exchange_declare($exchange, '', true); 332 | $channel->close(); 333 | 334 | $this->exchanges[] = $exchange; 335 | 336 | return true; 337 | } catch (AMQPProtocolChannelException $exception) { 338 | if ($exception->amqp_reply_code === 404) { 339 | return false; 340 | } 341 | 342 | throw $exception; 343 | } 344 | } 345 | 346 | /** 347 | * Declare an exchange in rabbitMQ, when not already declared. 348 | */ 349 | public function declareExchange( 350 | string $name, 351 | string $type = AMQPExchangeType::DIRECT, 352 | bool $durable = true, 353 | bool $autoDelete = false, 354 | array $arguments = [] 355 | ): void { 356 | if ($this->isExchangeDeclared($name)) { 357 | return; 358 | } 359 | 360 | $this->getChannel()->exchange_declare( 361 | $name, 362 | $type, 363 | false, 364 | $durable, 365 | $autoDelete, 366 | false, 367 | true, 368 | new AMQPTable($arguments) 369 | ); 370 | } 371 | 372 | /** 373 | * Delete an exchange from rabbitMQ, only when present in RabbitMQ. 374 | * 375 | * 376 | * @throws AMQPProtocolChannelException 377 | */ 378 | public function deleteExchange(string $name, bool $unused = false): void 379 | { 380 | if (! $this->isExchangeExists($name)) { 381 | return; 382 | } 383 | 384 | $idx = array_search($name, $this->exchanges); 385 | unset($this->exchanges[$idx]); 386 | 387 | $this->getChannel()->exchange_delete( 388 | $name, 389 | $unused 390 | ); 391 | } 392 | 393 | /** 394 | * Checks if the given queue already present/defined in RabbitMQ. 395 | * Returns false when the queue is missing. 396 | * 397 | * 398 | * @throws AMQPProtocolChannelException 399 | */ 400 | public function isQueueExists(?string $name = null): bool 401 | { 402 | $queueName = $this->getQueue($name); 403 | 404 | if ($this->isQueueDeclared($queueName)) { 405 | return true; 406 | } 407 | 408 | try { 409 | // create a temporary channel, so the main channel will not be closed on exception 410 | $channel = $this->createChannel(); 411 | $channel->queue_declare($queueName, true); 412 | $channel->close(); 413 | 414 | $this->queues[] = $queueName; 415 | 416 | return true; 417 | } catch (AMQPProtocolChannelException $exception) { 418 | if ($exception->amqp_reply_code === 404) { 419 | return false; 420 | } 421 | 422 | throw $exception; 423 | } 424 | } 425 | 426 | /** 427 | * Declare a queue in rabbitMQ, when not already declared. 428 | */ 429 | public function declareQueue( 430 | string $name, 431 | bool $durable = true, 432 | bool $autoDelete = false, 433 | array $arguments = [] 434 | ): void { 435 | if ($this->isQueueDeclared($name)) { 436 | return; 437 | } 438 | 439 | $this->getChannel()->queue_declare( 440 | $name, 441 | false, 442 | $durable, 443 | false, 444 | $autoDelete, 445 | false, 446 | new AMQPTable($arguments) 447 | ); 448 | } 449 | 450 | /** 451 | * Delete a queue from rabbitMQ, only when present in RabbitMQ. 452 | * 453 | * 454 | * @throws AMQPProtocolChannelException 455 | */ 456 | public function deleteQueue(string $name, bool $if_unused = false, bool $if_empty = false): void 457 | { 458 | if (! $this->isQueueExists($name)) { 459 | return; 460 | } 461 | 462 | $idx = array_search($name, $this->queues); 463 | unset($this->queues[$idx]); 464 | 465 | $this->getChannel()->queue_delete($name, $if_unused, $if_empty); 466 | } 467 | 468 | /** 469 | * Bind a queue to an exchange. 470 | */ 471 | public function bindQueue(string $queue, string $exchange, string $routingKey = ''): void 472 | { 473 | if (in_array( 474 | implode('', compact('queue', 'exchange', 'routingKey')), 475 | $this->boundQueues, 476 | true 477 | )) { 478 | return; 479 | } 480 | 481 | $this->getChannel()->queue_bind($queue, $exchange, $routingKey); 482 | } 483 | 484 | /** 485 | * Purge the queue of messages. 486 | */ 487 | public function purge(?string $queue = null): void 488 | { 489 | // create a temporary channel, so the main channel will not be closed on exception 490 | $channel = $this->createChannel(); 491 | $channel->queue_purge($this->getQueue($queue)); 492 | $channel->close(); 493 | } 494 | 495 | /** 496 | * Acknowledge the message. 497 | */ 498 | public function ack(RabbitMQJob $job): void 499 | { 500 | $this->getChannel()->basic_ack($job->getRabbitMQMessage()->getDeliveryTag()); 501 | } 502 | 503 | /** 504 | * Reject the message. 505 | */ 506 | public function reject(RabbitMQJob $job, bool $requeue = false): void 507 | { 508 | $this->getChannel()->basic_reject($job->getRabbitMQMessage()->getDeliveryTag(), $requeue); 509 | } 510 | 511 | /** 512 | * Create a AMQP message. 513 | */ 514 | protected function createMessage($payload, int $attempts = 0): array 515 | { 516 | $properties = [ 517 | 'content_type' => 'application/json', 518 | 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, 519 | ]; 520 | 521 | $currentPayload = json_decode($payload, true); 522 | if ($correlationId = $currentPayload['id'] ?? null) { 523 | $properties['correlation_id'] = $correlationId; 524 | } 525 | 526 | if ($this->getRabbitMQConfig()->isPrioritizeDelayed()) { 527 | $properties['priority'] = $attempts; 528 | } 529 | 530 | if (isset($currentPayload['data']['command'])) { 531 | // If the command data is encrypted, decrypt it first before attempting to unserialize 532 | if (is_subclass_of($currentPayload['data']['commandName'], ShouldBeEncrypted::class)) { 533 | $currentPayload['data']['command'] = Crypt::decrypt($currentPayload['data']['command']); 534 | } 535 | 536 | $commandData = unserialize($currentPayload['data']['command']); 537 | if (property_exists($commandData, 'priority')) { 538 | $properties['priority'] = $commandData->priority; 539 | } 540 | } 541 | 542 | $message = new AMQPMessage($payload, $properties); 543 | 544 | $message->set('application_headers', new AMQPTable([ 545 | 'laravel' => [ 546 | 'attempts' => $attempts, 547 | ], 548 | ])); 549 | 550 | return [ 551 | $message, 552 | $correlationId, 553 | ]; 554 | } 555 | 556 | /** 557 | * Create a payload array from the given job and data. 558 | * 559 | * @param string|object $job 560 | * @param string $queue 561 | * @param mixed $data 562 | */ 563 | protected function createPayloadArray($job, $queue, $data = ''): array 564 | { 565 | return array_merge(parent::createPayloadArray($job, $queue, $data), [ 566 | 'id' => $this->getRandomId(), 567 | ]); 568 | } 569 | 570 | /** 571 | * Get a random ID string. 572 | */ 573 | protected function getRandomId(): string 574 | { 575 | return Str::uuid(); 576 | } 577 | 578 | /** 579 | * Close the connection to RabbitMQ. 580 | * 581 | * 582 | * @throws Exception 583 | */ 584 | public function close(): void 585 | { 586 | if (isset($this->currentJob) && ! $this->currentJob->isDeletedOrReleased()) { 587 | $this->reject($this->currentJob, true); 588 | } 589 | 590 | try { 591 | $this->getConnection()->close(); 592 | } catch (ErrorException) { 593 | // Ignore the exception 594 | } 595 | } 596 | 597 | /** 598 | * Get the Queue arguments. 599 | */ 600 | protected function getQueueArguments(string $destination): array 601 | { 602 | $arguments = []; 603 | 604 | // Messages without a priority property are treated as if their priority were 0. 605 | // Messages with a priority which is higher than the queue's maximum, are treated as if they were 606 | // published with the maximum priority. 607 | // Quorum queues does not support priority. 608 | if ($this->getRabbitMQConfig()->isPrioritizeDelayed() && ! $this->getRabbitMQConfig()->isQuorum()) { 609 | $arguments['x-max-priority'] = $this->getRabbitMQConfig()->getQueueMaxPriority(); 610 | } 611 | 612 | if ($this->getRabbitMQConfig()->isRerouteFailed()) { 613 | $arguments['x-dead-letter-exchange'] = $this->getFailedExchange(); 614 | $arguments['x-dead-letter-routing-key'] = $this->getFailedRoutingKey($destination); 615 | } 616 | 617 | if ($this->getRabbitMQConfig()->isQuorum()) { 618 | $arguments['x-queue-type'] = 'quorum'; 619 | } 620 | 621 | return $arguments; 622 | } 623 | 624 | /** 625 | * Get the Delay queue arguments. 626 | */ 627 | protected function getDelayQueueArguments(string $destination, int $ttl): array 628 | { 629 | return [ 630 | 'x-dead-letter-exchange' => $this->getExchange(), 631 | 'x-dead-letter-routing-key' => $this->getRoutingKey($destination), 632 | 'x-message-ttl' => $ttl, 633 | 'x-expires' => $ttl * 2, 634 | ]; 635 | } 636 | 637 | /** 638 | * Get the exchange name, or empty string; as default value. 639 | */ 640 | protected function getExchange(?string $exchange = null): string 641 | { 642 | return $exchange ?? $this->getRabbitMQConfig()->getExchange(); 643 | } 644 | 645 | /** 646 | * Get the routing-key for when you use exchanges 647 | * The default routing-key is the given destination. 648 | */ 649 | protected function getRoutingKey(string $destination): string 650 | { 651 | return ltrim(sprintf($this->getRabbitMQConfig()->getExchangeRoutingKey(), $destination), '.'); 652 | } 653 | 654 | /** 655 | * Get the exchangeType, or AMQPExchangeType::DIRECT as default. 656 | */ 657 | protected function getExchangeType(?string $type = null): string 658 | { 659 | $constant = AMQPExchangeType::class.'::'.Str::upper($type ?: $this->getRabbitMQConfig()->getExchangeType()); 660 | 661 | return defined($constant) ? constant($constant) : AMQPExchangeType::DIRECT; 662 | } 663 | 664 | /** 665 | * Get the exchange for failed messages. 666 | */ 667 | protected function getFailedExchange(?string $exchange = null): string 668 | { 669 | return $exchange ?? $this->getRabbitMQConfig()->getFailedExchange(); 670 | } 671 | 672 | /** 673 | * Get the routing-key for failed messages 674 | * The default routing-key is the given destination substituted by '.failed'. 675 | */ 676 | protected function getFailedRoutingKey(string $destination): string 677 | { 678 | return ltrim(sprintf($this->getRabbitMQConfig()->getFailedRoutingKey(), $destination), '.'); 679 | } 680 | 681 | /** 682 | * Checks if the exchange was already declared. 683 | */ 684 | protected function isExchangeDeclared(string $name): bool 685 | { 686 | return in_array($name, $this->exchanges, true); 687 | } 688 | 689 | /** 690 | * Checks if the queue was already declared. 691 | */ 692 | protected function isQueueDeclared(string $name): bool 693 | { 694 | return in_array($name, $this->queues, true); 695 | } 696 | 697 | /** 698 | * Declare the destination when necessary. 699 | * 700 | * @throws AMQPProtocolChannelException 701 | */ 702 | protected function declareDestination(string $destination, ?string $exchange = null, string $exchangeType = AMQPExchangeType::DIRECT): void 703 | { 704 | // When an exchange is provided and no exchange is present in RabbitMQ, create an exchange. 705 | if ($exchange && ! $this->isExchangeExists($exchange)) { 706 | $this->declareExchange($exchange, $exchangeType); 707 | } 708 | 709 | // When an exchange is provided, just return. 710 | if ($exchange) { 711 | return; 712 | } 713 | 714 | // When the queue already exists, just return. 715 | if ($this->isQueueExists($destination)) { 716 | return; 717 | } 718 | 719 | // Create a queue for amq.direct publishing. 720 | $this->declareQueue($destination, true, false, $this->getQueueArguments($destination)); 721 | } 722 | 723 | /** 724 | * Determine all publish properties. 725 | */ 726 | protected function publishProperties($queue, array $options = []): array 727 | { 728 | $queue = $this->getQueue($queue); 729 | $attempts = Arr::get($options, 'attempts') ?: 0; 730 | 731 | $destination = $this->getRoutingKey($queue); 732 | $exchange = $this->getExchange(Arr::get($options, 'exchange')); 733 | $exchangeType = $this->getExchangeType(Arr::get($options, 'exchange_type')); 734 | 735 | return [$destination, $exchange, $exchangeType, $attempts]; 736 | } 737 | 738 | protected function getRabbitMQConfig(): QueueConfig 739 | { 740 | return $this->rabbitMQConfig; 741 | } 742 | 743 | /** 744 | * @throws AMQPChannelClosedException 745 | * @throws AMQPConnectionClosedException 746 | * @throws AMQPConnectionBlockedException 747 | */ 748 | protected function publishBasic($msg, $exchange = '', $destination = '', $mandatory = false, $immediate = false, $ticket = null): void 749 | { 750 | $this->getChannel()->basic_publish($msg, $exchange, $destination, $mandatory, $immediate, $ticket); 751 | } 752 | 753 | protected function batchPublish(): void 754 | { 755 | $this->getChannel()->publish_batch(); 756 | } 757 | 758 | public function getChannel($forceNew = false): AMQPChannel 759 | { 760 | if (! $this->channel || $forceNew) { 761 | $this->channel = $this->createChannel(); 762 | } 763 | 764 | return $this->channel; 765 | } 766 | 767 | protected function createChannel(): AMQPChannel 768 | { 769 | return $this->getConnection()->channel(); 770 | } 771 | 772 | /** 773 | * @throws Exception 774 | */ 775 | protected function reconnect(): void 776 | { 777 | // Reconnects using the original connection settings. 778 | $this->getConnection()->reconnect(); 779 | // Create a new main channel because all old channels are removed. 780 | $this->getChannel(true); 781 | } 782 | } 783 | --------------------------------------------------------------------------------