An each method to add one job for each item of a collection without breaking the fluent interface
The concept of a group and GroupDependency, so a job is able to depend on all jobs added by each without having to list them all manually
Details
I frequently found myself in the position where I needed to add multiple instances of the same job to the workflow in a loop. Consider an example like this.
class SendCampaignWorkflow extends AbstractWorkflow
{
public function __construct(private readonly Campaign $campaign)
{
}
public function definition(): WorkflowDefinition
{
$definition = $this->define('Send email campaign');
foreach ($this->campaign->users as $i => $user) {
$definition->addJob(
new SpamUser($this->campaign, $user),
id: 'spam_user_' . $i
);
}
// Other stuff...
return $definition;
}
}
Here, I want to add a SpamUser job to the workflow for each of the Campaign's users. Currently, this is very annoying because I have to break out of the fluent interface to some kind of loop. I also need to make sure to provide a unique id to each of these jobs by including the loop variable in the job id.
This gets even worse when I want to add a job that should only run once all of the SpamUser jobs have finished, as I would have to list each job id in the new job's dependencies.
One way to work around this would be to extract the SpamUser jobs into their own workflow and have the loop be there instead. Then I could simply add this new workflow as a nested workflow. This would also allow me to use the workflow as a dependency for other jobs, eliminating the need to enumerate each individual job id.
While this works, creating a whole separate workflow to essentially just wrap a foreach loop feels very clunky.
Solution
With this PR, you would be able to use the new each method of the WorkflowDefinition to achieve the same thing, while still maintaining the ability to continue chaining.
class SendCampaignWorkflow extends AbstractWorkflow
{
public function __construct(private readonly Campaign $campaign)
{
}
public function definition(): WorkflowDefinition
{
return $this->define('Send email campaign')
->each(
$this->campaign->users,
fn (User $user) => new SpamUser($this->campaign, $user),
)
->addJob(new PatYourselfOnTheBack());
}
}
The each method takes in a collection of items and a callback function. The callback function gets called for each item in the collection and should either return a WorkflowableJob or an AbstractWorkflow. Each job or nested workflow will then get added to the parent workflow.
Note that I didn't have to provide an explicit id anymore as the each method will fall back to using the registered StepIdGenerator internally. It will also enumerate each job id as we had done manually above. Similar to how addJob works, you're still able to provide an explicit ID, however. This id will still get enumerated to avoid duplicate job ids.
$this->define('Send email campaign')
->each(
$this->campaign->users,
fn (User $user) => new SpamUser($this->campaign, $user),
// Will get turned into "spam_users_1", "spam_users_2", etc
id: 'spam_users'
);
You're also able to provide a name, delay, as well as an array of dependencies to this method. All of these get set on each job.
Groups
While this solves the issue of defining these jobs, there's still no good way of depending on this list of jobs. For this reason, this PR also adds the concept of groups to the DependencyGraph.
A group is essentially just a list of nodes (read: jobs), that you want to be able to address with a single name. This way, you can add a GroupDependency to a job and have it automatically resolve the correct dependencies.
The only place where a group is currently created, is when using the each method and defining an explicit id. The definition will then register all added jobs and workflows as a group in the dependency graph. The name of the group will be the provided id
class SendCampaignWorkflow extends AbstractWorkflow
{
public function __construct(private readonly Campaign $campaign)
{
}
public function definition(): WorkflowDefinition
{
return $this->define('Send email campaign')
->each(
$this->campaign->users,
fn (User $user) => new SpamUser($this->campaign, $user),
// Providing an explicit id defines a group for all jobs or workflows that were
// added by the `each` call
id: 'spam_users'
)
->addJob(
new PatYourselfOnTheBack(),
// We can then depend on the entire group by using a `GroupDependency`
dependencies: [GroupDependency::forGroup('spam_users')]
);
}
}
A GroupDependency will also correctly resolve the required dependencies for any nested workflows that might have been added to the parent workflow.
Summary
This PR introduces two new features:
each
method to add one job for each item of a collection without breaking the fluent interfacegroup
andGroupDependency
, so a job is able to depend on all jobs added byeach
without having to list them all manuallyDetails
I frequently found myself in the position where I needed to add multiple instances of the same job to the workflow in a loop. Consider an example like this.
Here, I want to add a
SpamUser
job to the workflow for each of theCampaign
'susers
. Currently, this is very annoying because I have to break out of the fluent interface to some kind of loop. I also need to make sure to provide a unique id to each of these jobs by including the loop variable in the job id.This gets even worse when I want to add a job that should only run once all of the
SpamUser
jobs have finished, as I would have to list each job id in the new job'sdependencies
.One way to work around this would be to extract the
SpamUser
jobs into their own workflow and have the loop be there instead. Then I could simply add this new workflow as a nested workflow. This would also allow me to use the workflow as a dependency for other jobs, eliminating the need to enumerate each individual job id.While this works, creating a whole separate workflow to essentially just wrap a
foreach
loop feels very clunky.Solution
With this PR, you would be able to use the new
each
method of theWorkflowDefinition
to achieve the same thing, while still maintaining the ability to continue chaining.The
each
method takes in a collection of items and a callback function. The callback function gets called for each item in the collection and should either return aWorkflowableJob
or anAbstractWorkflow
. Each job or nested workflow will then get added to the parent workflow.Note that I didn't have to provide an explicit id anymore as the
each
method will fall back to using the registeredStepIdGenerator
internally. It will also enumerate each job id as we had done manually above. Similar to howaddJob
works, you're still able to provide an explicit ID, however. This id will still get enumerated to avoid duplicate job ids.You're also able to provide a
name
,delay
, as well as an array ofdependencies
to this method. All of these get set on each job.Groups
While this solves the issue of defining these jobs, there's still no good way of depending on this list of jobs. For this reason, this PR also adds the concept of
groups
to theDependencyGraph
.A group is essentially just a list of nodes (read: jobs), that you want to be able to address with a single name. This way, you can add a
GroupDependency
to a job and have it automatically resolve the correct dependencies.The only place where a group is currently created, is when using the
each
method and defining an explicitid
. The definition will then register all added jobs and workflows as a group in the dependency graph. The name of the group will be the providedid
A
GroupDependency
will also correctly resolve the required dependencies for any nested workflows that might have been added to the parent workflow.