Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions docs/sections/custom_tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,59 @@ Make sure it throws an exception with a clear error message in case of failure.
Note: You can use the provided `Queue\Model\QueueException` if you do not need to include a stack trace.
This is usually the default inside custom tasks.

## DI Container Example
## DI Container

If you use the [Dependency Injection Container](https://book.cakephp.org/5/en/development/dependency-injection.html) provided by CakePHP you can also use
it inside your tasks.

### Constructor Injection

Tasks registered in the DI container can receive dependencies through their constructor, the same way CakePHP Components and Commands do.

First, register the task and its dependencies in your `Application::services()`:

```php
use App\Queue\Task\MyCustomTask;
use App\Service\MyService;

public function services(ContainerInterface $container): void {
$container->add(MyService::class);
$container->add(MyCustomTask::class)
->addArgument(MyService::class);
}
```

Then declare the dependency in your task's constructor:

```php
namespace App\Queue\Task;

use App\Service\MyService;
use Queue\Queue\Task;

class MyCustomTask extends Task {

public function __construct(
protected readonly MyService $myService,
) {
parent::__construct();
}

public function run(array $data, int $jobId): void {
$this->myService->doWork($data);
}

}
```

The Processor injects its own runtime `Io` and `LoggerInterface` via `setIo()` / `setLogger()` after resolving the task, so your constructor only needs to declare your own dependencies. Call `parent::__construct()` with no arguments.

Tasks not registered in the container continue to work exactly as before.

### ServicesTrait

Alternatively, you can use the [ServicesTrait](https://github.com/dereuromark/cakephp-queue/blob/master/src/Queue/ServicesTrait.php) to pull services from the container at runtime inside `run()`:

```php
use Queue\Queue\ServicesTrait;

Expand All @@ -77,7 +125,7 @@ class MyCustomTask extends Task {
}
```

As you see here you have to add the [ServicesTrait](https://github.com/dereuromark/cakephp-queue/blob/master/src/Queue/ServicesTrait.php) to your task which then allows you to use the `$this->getService()` method.
Note that `getService()` cannot be called in the constructor, only inside `run()` or other methods invoked after the container has been set.

## Organize tasks in sub folders

Expand Down
13 changes: 11 additions & 2 deletions src/Queue/Processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -661,12 +661,21 @@ protected function getConfig(array $args): array {
*/
protected function loadTask(string $taskName): TaskInterface {
$className = $this->getTaskClass($taskName);
/** @var \Queue\Queue\Task $task */
$task = new $className($this->io, $this->logger);

if ($this->container && $this->container->has($className)) {
$task = $this->container->get($className);
} else {
$task = new $className();
Copy link
Copy Markdown
Owner

@dereuromark dereuromark Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you reverted the above new $className($this->io, $this->logger); - should stay as is.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit confused, it was constructor args before:

new $className($this->io, $this->logger);

Why are we now using setters?

	$task->setIo($this->io);
	$task->setLogger($this->logger);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even though those args are nullable I agree. It needs to be new $className($this->io, $this->logger); to be BC with what is already present for basically any task out there.

To get true constructor based DI here we'd need to refactor the Task class to do its constructor logic somewhere else (like a new ->initialize() method) and have a "clean" constructor.

This would be a major change.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like pulling from the container is fine though:

	if ($this->container && $this->container->has($className)) {
		$task = $this->container->get($className);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like pulling from the container is fine though

I was hoping to add support for the constructor-based DI use case, since that's in line with how core Cake works, but it seems that would be more difficult than I had originally anticipated.

}

if (!$task instanceof TaskInterface) {
throw new RuntimeException('Task must implement ' . TaskInterface::class);
}

/** @var \Queue\Queue\Task $task */
$task->setIo($this->io);
$task->setLogger($this->logger);

return $task;
}

Expand Down
18 changes: 18 additions & 0 deletions src/Queue/Task.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ public function __construct(?Io $io = null, ?LoggerInterface $logger = null) {
}
}

/**
* @param \Queue\Console\Io $io IO
*
* @return void
*/
public function setIo(Io $io): void {
$this->io = $io;
}

/**
* @param \Psr\Log\LoggerInterface $logger
*
* @return void
*/
public function setLogger(LoggerInterface $logger): void {
$this->logger = $logger;
}

/**
* @throws \InvalidArgumentException
*
Expand Down
2 changes: 1 addition & 1 deletion tests/TestCase/Command/InfoCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function testExecute(): void {
$this->exec('queue info');

$output = $this->_out->output();
$this->assertStringContainsString('15 tasks available:', $output);
$this->assertStringContainsString('16 tasks available:', $output);
$this->assertExitCode(0);
}

Expand Down
110 changes: 110 additions & 0 deletions tests/TestCase/Queue/ProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Cake\Console\CommandInterface;
use Cake\Console\ConsoleIo;
use Cake\Core\Configure;
use Cake\Core\Container;
use Cake\Datasource\ConnectionManager;
use Cake\Event\EventList;
use Cake\Event\EventManager;
Expand All @@ -21,6 +22,8 @@
use RuntimeException;
use Shim\TestSuite\ConsoleOutput;
use Shim\TestSuite\TestTrait;
use TestApp\Queue\Task\InjectedTask;
use TestApp\Services\TestService;
use const SIGTERM;

class ProcessorTest extends TestCase {
Expand Down Expand Up @@ -442,4 +445,111 @@ public function testSetPhpTimeoutWithDeprecatedConfig() {
Configure::delete('Queue.workertimeout');
}

/**
* Test that loadTask() resolves a task from the DI container when registered.
*
* @return void
*/
public function testLoadTaskResolvesFromContainer(): void {
$container = new Container();
$container->add(ExampleTask::class);

$out = new ConsoleOutput();
$processorIo = new Io(new ConsoleIo($out));
$processorLogger = new NullLogger();
$processor = new Processor($processorIo, $processorLogger, $container);

// Set taskConf so getTaskClass() can resolve the name
$reflection = new ReflectionClass($processor);
$taskConfProp = $reflection->getProperty('taskConf');
$taskConfProp->setValue($processor, [
'Queue.Example' => ['class' => ExampleTask::class],
]);

$task = $this->invokeMethod($processor, 'loadTask', ['Queue.Example']);

$this->assertInstanceOf(ExampleTask::class, $task);

// Verify the task received the Processor's Io and Logger, not defaults
$taskReflection = new ReflectionClass($task);
$ioProp = $taskReflection->getProperty('io');
$this->assertSame($processorIo, $ioProp->getValue($task), 'Task should have the Processor Io');

$loggerProp = $taskReflection->getProperty('logger');
$this->assertSame($processorLogger, $loggerProp->getValue($task), 'Task should have the Processor Logger');
}

/**
* Test that loadTask() falls back to direct instantiation when the task
* is not registered in the container.
*
* @return void
*/
public function testLoadTaskFallsBackWithoutContainerRegistration(): void {
$container = new Container();
// Intentionally do NOT register ExampleTask in the container

$out = new ConsoleOutput();
$processorIo = new Io(new ConsoleIo($out));
$processorLogger = new NullLogger();
$processor = new Processor($processorIo, $processorLogger, $container);

$reflection = new ReflectionClass($processor);
$taskConfProp = $reflection->getProperty('taskConf');
$taskConfProp->setValue($processor, [
'Queue.Example' => ['class' => ExampleTask::class],
]);

$task = $this->invokeMethod($processor, 'loadTask', ['Queue.Example']);

$this->assertInstanceOf(ExampleTask::class, $task);

// Verify the task still received the Processor's Io and Logger
$taskReflection = new ReflectionClass($task);
$ioProp = $taskReflection->getProperty('io');
$this->assertSame($processorIo, $ioProp->getValue($task), 'Fallback task should have the Processor Io');

$loggerProp = $taskReflection->getProperty('logger');
$this->assertSame($processorLogger, $loggerProp->getValue($task), 'Fallback task should have the Processor Logger');
}

/**
* Test that loadTask() resolves a task with constructor-injected dependencies
* from the DI container.
*
* @return void
*/
public function testLoadTaskWithConstructorDependencyInjection(): void {
$testService = new TestService();

$container = new Container();
$container->add(InjectedTask::class)
->addArgument($testService);

$out = new ConsoleOutput();
$processorIo = new Io(new ConsoleIo($out));
$processorLogger = new NullLogger();
$processor = new Processor($processorIo, $processorLogger, $container);

$reflection = new ReflectionClass($processor);
$taskConfProp = $reflection->getProperty('taskConf');
$taskConfProp->setValue($processor, [
'Injected' => ['class' => InjectedTask::class],
]);

/** @var \TestApp\Queue\Task\InjectedTask $task */
$task = $this->invokeMethod($processor, 'loadTask', ['Injected']);

$this->assertInstanceOf(InjectedTask::class, $task);
$this->assertSame($testService, $task->getTestService(), 'Task should have the injected TestService');

// Verify Io and Logger were set by the Processor
$taskReflection = new ReflectionClass($task);
$ioProp = $taskReflection->getProperty('io');
$this->assertSame($processorIo, $ioProp->getValue($task), 'DI task should have the Processor Io');

$loggerProp = $taskReflection->getProperty('logger');
$this->assertSame($processorLogger, $loggerProp->getValue($task), 'DI task should have the Processor Logger');
}

}
4 changes: 2 additions & 2 deletions tests/TestCase/Queue/Task/ExecuteTaskTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public function testRunFailureWithRedirect() {
$this->assertSame('Failed with error code 127: `fooooobbbaraar -eeee 2>&1`', $exception->getMessage());

$this->assertTextContains('Error (code 127)', $this->err->output());
$this->assertTextContains('fooooobbbaraar: not found', $this->out->output());
$this->assertMatchesRegularExpression('/fooooobbbaraar.*not found/', $this->out->output());
}

/**
Expand All @@ -87,7 +87,7 @@ public function testRunFailureWithRedirectAndIgnoreCode() {
$this->Task->run(['command' => 'fooooobbbaraar -eeee', 'accepted' => []], 0);

$this->assertTextContains('Success (code 127)', $this->out->output());
$this->assertTextContains('fooooobbbaraar: not found', $this->out->output());
$this->assertMatchesRegularExpression('/fooooobbbaraar.*not found/', $this->out->output());
}

/**
Expand Down
33 changes: 33 additions & 0 deletions tests/test_app/src/Queue/Task/InjectedTask.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);

namespace TestApp\Queue\Task;

use Queue\Queue\Task;
use TestApp\Services\TestService;

/**
* Test task that uses constructor-based dependency injection.
*/
class InjectedTask extends Task {

public ?int $timeout = 10;

public function __construct(
protected readonly TestService $testService,
) {
parent::__construct();
}

public function run(array $data, int $jobId): void {
$this->io->out($this->testService->output());
}

/**
* Expose the injected service for test assertions.
*/
public function getTestService(): TestService {
return $this->testService;
}

}
Loading