Describes the recommended workflow in steps in order to build a feature with the most efficient use of time, showcasing how Lucid enhances productivity and efficiency.
Building applications is as similar in concept as building any artefact made of multiple pieces such as houses, towers, castles etc. which are all built on the basis of the same construct - stone walls. For that we need material, structure, measurements and most importantly sequence; for we need the base layer of stones and cement to build upon, and it is built row by row.
In this guide we show the recommended process of building robust (yet flexible) blocks that make our application whole, ensuring ideal levels of productivity and efficiency by outlining the composition and interaction of our components prior to indulging into implementation - Thinking time as described in Peopleware is the objective of this workflow.
Writing Features
Generate a Feature class, which will create its test class along.
Open the Feature class and describe the steps to achieve the feature's functionality in TODO items such as:
class UpdateResourceFeature extends Feature
{
public function handle(Request $request)
{
// 1. @TODO: validate request input (format & length)
// 2. @TODO: verify access to resource
// 3. @TODO: update resource
// 4. @TODO: save audit activity (async)
// 5. @TODO: notify followers (async)
// 6. @TODO: respond with update status
}
}
This approach gives the benefit of an organised and defined workflow, it enhances productivity, focus and visibility by summarising the steps as an overview of what it is to be implemented. In addition to the benefit of editors listing those when needed:
Start by writing the test for the feature: it is a request to call the route with one of the acceptance cases with input params (if applicable) to execute this feature. At this step, we will have to create the route, controller and controller method to serve the feature, and add the serve(UpdateResourceFeature::class) line to the controller method.
Now that we've written our first test case to execute the feature, it sure will fail because our Feature class is still empty. Now, we can start filling this feature with jobs by replacing each of these steps with the Job or Operation class that fulfils it, following the workflows below.
Writing Operations
Similar to Features, Operations group multiple Jobs that are often executed together, or their combination form a single piece of functionality that itself can be split into multiple reusable steps. However, they differ in testing.
Generate an Operation class, which will create its test class along.
Open the Operation class and describe the steps that implement the functionality in comments
class NotifyFollowersOperation extends Operation
{
public function handle(Request $request)
{
// 1. @TODO: query followers IDs (paginate)
// 2. @TODO: enqueue notifications in batches of 50
// 3. @TODO: return status
}
}
Start writing the test for the operation: It is a unit test to ensure one thing:
That it runs the correct jobs with the correct parameters, in the correct order.
Jobs don't really run within an Operation test, we rely on the unit test of the single job to ensure its integrity, while operations are about running multiple jobs in sequence. We achieve this by partially mocking the Operation class.
With this we have drawn a map of how we would like our codebase to be composed on the outside, and what is expected from each component for this implementation. It is now that we start detecting reusable components and we adjust as desired. For example we might figure that a similar functionality is already implemented and needs a simple adjustment to be used in the current operation or feature, so we go ahead and include a @TODO item to update it.
Of course, this is an iterative process where we rarely get things right from the first time so feel free to draft as many possibilities until you are satisfied with the final result, then start filling Job classes with implementation.
At this stage we would have had to create our Job classes so that we cloud use them in the test of our Operation and Feature classes, next will be to fill those Jobs with implementation.
Writing Jobs
Resulting from our previous steps of the workflow, a list of tasks that we can start ticking off and our feature will be implemented completely by the end. All that is left is to start filling Job classes with implementations.
If you hadn't generated the Job class previously, generate a Job class for each of the items previously marked as a "to do" item. It is usually best to generate one class at a time, fill it, ensure that it works as expected and that it provides the functionality required and then move on to the next. As for the sequence of generating the jobs, it is usually best to follow the sequence in the Feature class.
Open the generated Job class and start writing functionality. If you are into TDD, you may prefer to open the test file and write your expectations first. Nevertheless, it is not important nor does it affect the outcome to whether you write the test or the implementation first; whichever suites you best.
namespace App\Domains\Article\Tests\Jobs;
use App\Domains\Article\Jobs\ValidateArticleInputJob;
use Lucid\Foundation\Validator;
use Tests\TestCase;
class ValidateArticleInptJobTest extends TestCase
{
public function test_validate_article_input_job()
{
$job = new ValidateArticleInputJob([
'title' => 'The Title',
'content' => 'The content of the article goes here.',
]);
$this->assertTrue($job->handle(app(Validator::class)));
}
/**
* @dataProvider articleInputValidationProvider
* @expectedException \Lucid\Foundation\InvalidInputException
*/
public function test_validating_article_input_job_rules($title = null, $content = null)
{
$job = new ValidateArticleInputJob(compact('title', 'content'));
$job->handle(app(Validator::class));
}
public function articleInputValidationProvider()
{
return [
'without title' => [
'content' => 'The content of the article.',
],
'title is empty' => [
'title' => '',
'content' => 'The content of the article.',
],
'without content' => [
'title' => 'The Title Only',
],
'content is empty' => [
'title' => 'The Title Here',
'content' => '',
],
'max title length' => [
'title' => str_repeat('a', 101),
'content' => 'Content goes here',
],
'max content length' => [
'title' => 'Title here',
'content' => str_repeat('a', 1001),
],
];
}
}
Running the tests now will yield the following results
PHPUnit 7.5.8 by Sebastian Bergmann and contributors.
.......I.. 10 / 10 (100%)
Time: 351 ms, Memory: 18.00 MB
OK, but incomplete, skipped, or risky tests!
Tests: 10, Assertions: 9, Incomplete: 1.
I know, the incomplete test is annoying, but it is there for a purpose! This is the Feature test, telling us that we're not done yet. Lucid test files are initially generated with a markTestIncomplete for that purpose.
Step 2 - Authorise Access
// 2. @TODO: verify access to resource
Generate Job with lucid make:job AuthoriseArticleUpdateJob Article
This job will require some setup before we can dive into it since it deals with database. We will use SQLite for testing, which will work as-is with any other SQL database, thanks to Eloquent.
Update phpunit.xml to run the tests in an SQLite memory instance
A fix is required for factories to work, go to database/factories/ModelFactory.php and change the namespace of User::class to App\Data\Models\User::class
Generate a migration to create our Article model's table
php artisan make:migration create_articles_table
Fill the migration as follows
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateArticlesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('articles', function (Blueprint $table) {
$table->bigIncrements('id');
$table->timestamps();
$table->string('title');
$table->text('content');
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')
->references('id')->on('users')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('articles');
}
}
Now we can create our Article model. Create app/Data/Models/Article.php
app/Data/Models/Article.php
namespace App\Data\Models;
use Illuminate\Database\Eloquent\Model;
class Article extends Model
{
protected $fillable = ['title', 'content'];
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
}
Add the relationship User <> Article
public function articles()
{
return $this->hasMany(Article::class, 'user_id');
}
This job simply fetches the user with the intended article as a relationship with a firstOrFail which will throw a ModelNotFoundException if the record is not found, indicating that either one of the records doesn't exist (user or article) or the relationship between them doesn't exist, meaning that the intended article doesn't belong to this user. Then we write the test to ensure this functionality serves as intended.
namespace App\Domains\Article\Jobs;
use Lucid\Foundation\Job;
use App\Data\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class AuthoriseArticleUpdateJob extends Job
{
/**
* @var User
*/
private $user;
/**
* @var int
*/
private $articleId;
/**
* Create a new job instance.
*
* @param User $user
* @param int $articleId
*/
public function __construct(User $user, int $articleId)
{
$this->user = $user;
$this->articleId = $articleId;
}
/**
* Execute the job.
*
* @throws ModelNotFoundException
*/
public function handle()
{
// only the owner of the article is authorised to update it.
User::where('id', $this->user->id)
->whereHas('articles', function ($query) {
$query->where('id', $this->articleId);
})->firstOrFail();
}
}
namespace App\Domains\Article\Tests\Jobs;
use App\Data\Models\Article;
use App\Data\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use App\Domains\Article\Jobs\AuthoriseArticleUpdateJob;
use Tests\TestCase;
class AuthoriseArticleUpdateJobTest extends TestCase
{
use RefreshDatabase;
use DatabaseMigrations;
public function test_authorise_article_update_job()
{
$article = factory(Article::class)->create();
$user = $article->user;
$job = new AuthoriseArticleUpdateJob($user, $article->id);
// this usually throws ModelNotFoundException,
// if the user was not found. Not having anything returned is a good sign
$this->assertNull($job->handle());
}
/**
* @expectedException \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function test_fails_authorisation_for_different_user()
{
$article = factory(Article::class)->create();
$anotherUser = factory(User::class)->create();
$job = new AuthoriseArticleUpdateJob($anotherUser, $article->id);
// must throw ModelNotFoundException
$job->handle();
}
}
Step 3 - Save Article
// 3. @TODO: update resource
Generate Job lucid make:job SaveArticle Article
Saving an article is simple, we only need its identifier and the new attributes.
app/Domains/Article/Jobs/SaveArticleJob.php
namespace App\Domains\Article\Jobs;
use Lucid\Foundation\Job;
use App\Data\Models\Article;
class SaveArticleJob extends Job
{
/**
* @var string
*/
private $title;
/**
* @var string
*/
private $content;
/**
* @var int
*/
private $articleId;
/**
* Create a new job instance.
*
* @param int $articleId
* @param string $title
* @param string $content
*/
public function __construct(int $articleId, string $title, string $content)
{
$this->title = $title;
$this->content = $content;
$this->articleId = $articleId;
}
/**
* Execute the job.
*
* @return int The number of records that were changed.
* It will be 1 if update was successful.
* Otherwise it's a 0.
*/
public function handle() : int
{
return Article::where('id', $this->articleId)->update([
'title' => $this->title,
'content' => $this->content,
]);
}
}
tests/Domains/Article/Jobs/SaveArticleJobTest.php
namespace App\Domains\Article\Tests\Jobs;
use App\Data\Models\Article;
use App\Domains\Article\Jobs\SaveArticleJob;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class SaveArticleJobTest extends TestCase
{
use RefreshDatabase;
use DatabaseMigrations;
public function test_save_article_job()
{
$title = 'New Unique Title';
$content = 'Replaced content with this new piece.';
$article = factory(Article::class)->create();
$job = new SaveArticleJob($article->id, $title, $content);
// we expect one record to be updateed only.
$this->assertEquals(1, $job->handle());
}
}
Step 4 - Add Audit Entry Asynchronously
// 4. @TODO: save audit activity (async)
This is a fancy step that in most cases won't be needed, but it's been added to the workflow to showcase working with async using Laravel queues. Logging activity is simply adding a record to an activity database table, but adding activity should not be a blocking action for it is not required by the response to the user, to whom we should be responding as quick as possible.
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateActivityTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('activity', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('description');
$table->unsignedBigInteger('user_id');
$table->timestamps();
$table->foreign('user_id')
->references('id')->on('users')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('activity');
}
}
Create model app/Data/Models/ActivityLog.php
namespace App\Data\Models;
use Illuminate\Database\Eloquent\Model;
class ActivityLog extends Model
{
protected $table = 'activity';
protected $fillable = ['user_id', 'description'];
public function actor()
{
return $this->belongsTo(User::class, 'user_id');
}
}
You will notice that the generated job extends QueueableJob instead of Job which will instruct the dispatcher when calling $this->run(Job) in the Feature or Operation to run it asynchronously in Laravel queue as configured in the application.
app/Domains/Activity/Jobs/LogActivityJob.php
namespace App\Domains\Activity\Jobs;
use App\Data\Models\ActivityLog;
use Lucid\Foundation\QueueableJob;
class LogActivityJob extends QueueableJob
{
/**
* @var int
*/
private $userId;
/**
* @var string
*/
private $description;
/**
* Create a new job instance.
*
* @param int $userId
* @param string $description
*/
public function __construct(int $userId, string $description)
{
$this->userId = $userId;
$this->description = $description;
}
/**
* Execute the job.
*
* @return ActivityLog
*/
public function handle() : ActivityLog
{
return ActivityLog::create([
'user_id' => $this->userId,
'description' => $this->description,
]);
}
}
namespace App\Domains\Activity\Tests\Jobs;
use App\Data\Models\ActivityLog;
use App\Data\Models\User;
use App\Domains\Activity\Jobs\LogActivityJob;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class LogActivityJobTest extends TestCase
{
use RefreshDatabase;
use DatabaseMigrations;
public function test_log_activity_job()
{
$user = factory(User::class)->create();
$job = new LogActivityJob($user->id, "tested logging activity");
$activity = $job->handle();
$this->assertInstanceOf(ActivityLog::class, $activity);
$this->assertEquals($activity->user_id, $user->id);
$this->assertEquals($activity->description, "tested logging activity");
}
}
Step 5 - Notify Followers
// 5. @TODO: notify followers (async)
Notifying followers here is meant as if every follower of this author is subscribed to receive some sort of a notification every time this author publishes a new post. It is made of two steps:
Fetching a user's followers
Sending them notifications
Similar to our activity log, since it is not essential for the response to the user, we better do these steps asynchronously.
Since each of these steps should be in its own Job according to the Lucid principles, and so that we don't run two jobs every time we need to notify followers, Operations are handy in this scenario. But we will follow an outward approach where we start by creating the jobs and ensuring that they work as expected, then create the operation that runs them, just as we are doing with our Feature here. This approach is recommended because after finishing with the jobs we will know what to the Operation should expect as input parameters based on what the jobs require.
which will be our pivot table to link users with their followers.
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateFollowsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('follows', function (Blueprint $table) {
$table->unsignedBigInteger('author_id');
$table->unsignedBigInteger('follower_id');
$table->timestamps();
$table->foreign('author_id')
->references('id')->on('users')
->onDelete('cascade');
$table->foreign('follower_id')
->references('id')->on('users')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('follows');
}
}
Add relationship method and Notifiable trait to User model
class User extends Authenticatable
{
use Notifiable;
//...
public function followers()
{
return $this->belongsToMany(self::class,
'follows',
'author_id',
'follower_id')
->withTimestamps();
}
}
Generate Job to fetch user followers lucid make:job GetUserFollowers User
In a real-life scenario you should paginate followers instead of fetching them all at once, this is done this way to keep this demo simple.
app/Domains/User/Jobs/GetUserFollowersJob.php
namespace App\Domains\User\Jobs;
use Lucid\Foundation\Job;
use App\Data\Models\User;
use Illuminate\Database\Eloquent\Collection;
class GetUserFollowersJob extends Job
{
/**
* @var int
*/
private $userId;
/**
* Create a new job instance.
*
* @param int $userId
*/
public function __construct(int $userId)
{
$this->userId = $userId;
}
/**
* Execute the job.
*
* @return Collection of User models
*/
public function handle() : Collection
{
return User::find($this->userId)->followers;
}
}
namespace App\Domains\User\Tests\Jobs;
use App\Data\Models\User;
use App\Domains\User\Jobs\GetUserFollowersJob;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class GetUserFollowersJobTest extends TestCase
{
use RefreshDatabase;
use DatabaseMigrations;
public function test_get_user_followers_job()
{
// prepare
$user = factory(User::class)->create();
$followers = factory(User::class, 5)->create();
$user->followers()->saveMany($followers);
// test
$job = new GetUserFollowersJob($user->id);
$retreived = $job->handle();
$this->assertEquals($followers->toArray(),
$retreived->makeHidden(['pivot', 'email_verified_at'])->toArray());
}
}
Generate notification to be sent php artisan make:notification ArticlePublished
We will use this class as-is, no modifications are required here.
Generate Job to send notifications to users lucid make:job NotifyFollowers User
app/Domains/User/Jobs/NotifyFollowersJob.php
namespace App\Domains\User\Jobs;
use Lucid\Foundation\Job;
use App\Notifications\ArticlePublished;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Notification;
class NotifyFollowersJob extends Job
{
/**
* @var Collection
*/
private $followers;
/**
* Create a new job instance.
*
* @param Collection $followers of User models
*/
public function __construct(Collection $followers)
{
$this->followers = $followers;
}
/**
* Execute the job.
*/
public function handle()
{
Notification::send($this->followers, new ArticlePublished());
}
}
namespace App\Domains\User\Tests\Jobs;
use App\Data\Models\User;
use App\Notifications\ArticlePublished;
use Illuminate\Support\Facades\Notification;
use App\Domains\User\Jobs\NotifyFollowersJob;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class NotifyFollowersJobTest extends TestCase
{
use RefreshDatabase;
use DatabaseMigrations;
public function test_notify_followers_job()
{
$users = factory(User::class, 5)->create();
$job = new NotifyFollowersJob($users);
Notification::shouldReceive('send')
->once()->with($users, ArticlePublished::class);
$job->handle();
}
}
Generate Operation to run both jobs lucid make:operation NotifyFollowers --queue
And its test. The objective of testing operations is to ensure they run the correct jobs with the correct parameters in the correct sequence. It is not necessary to run the actual job because we would be testing the job twice while we should be keen not to reinvent the wheel nor replicating tests, we rely on the Job's tests to provide its guarantee.
app/Operations/NotifyFollowersOperation.php
namespace App\Operations;
use Lucid\Foundation\QueueableOperation;
use Illuminate\Http\Request;
use App\Domains\User\Jobs\NotifyFollowersJob;
use App\Domains\User\Jobs\GetUserFollowersJob;
class NotifyFollowersOperation extends QueueableOperation
{
/**
* @var int
*/
private $userId;
/**
* NotifyFollowersOperation constructor.
* @param int $userId
*/
public function __construct(int $userId)
{
$this->userId = $userId;
}
public function handle()
{
$followers = $this->run(GetUserFollowersJob::class, [
'userId' => $this->userId
]);
$this->run(NotifyFollowersJob::class, compact('followers'));
}
}
tests/Operations/NotifyFollowersOperationTest.php
namespace App\Tests\Operations;
use Mockery;
use Tests\TestCase;
use App\Data\Models\User;
use App\Operations\NotifyFollowersOperation;
use App\Domains\User\Jobs\NotifyFollowersJob;
use App\Domains\User\Jobs\GetUserFollowersJob;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class NotifyFollowersOperationTest extends TestCase
{
use RefreshDatabase;
use DatabaseMigrations;
public function tearDown(): void
{
parent::tearDown();
Mockery::close();
}
public function test_notifying_followers()
{
$user = factory(User::class)->create();
$followers = factory(User::class, 5)->create();
$op = Mockery::mock(NotifyFollowersOperation::class, [$user->id])
->makePartial();
$op->shouldReceive('run')
->once()->with(GetUserFollowersJob::class, ['userId' => $user->id])
->andReturn($followers);
$op->shouldReceive('run')
->once()->with(NotifyFollowersJob::class, compact('followers'));
$op->handle();
}
}
Step 6 - Respond with Update Status
Simply return a JSON response using built-in Http\RespondWithJsonJob as seen in the next step.
Step 7 - Finally: Compose Feature
Now that we have all the jobs ready and we're sure that they are working as expected through their unit tests, all we have to do now is run them in sequence in our Feature class and pass the correct parameters and everything should work as expected.
namespace App\Http\Controllers;
use Lucid\Foundation\Http\Controller;
use App\Features\UpdateArticleFeature;
class ArticleController extends Controller
{
public function update($articleId)
{
return $this->serve(UpdateArticleFeature::class,
['articleId' => intval($articleId)]);
}
}
Fill the feature's handle method and test it.
app/Features/UpdateArticleFeature.php
namespace App\Features;
use Auth;
use Lucid\Foundation\Feature;
use Illuminate\Http\Request;
use App\Domains\Article\Jobs\SaveArticleJob;
use App\Operations\NotifyFollowersOperation;
use App\Domains\Activity\Jobs\LogActivityJob;
use App\Domains\Http\Jobs\RespondWithJsonJob;
use App\Domains\Article\Jobs\ValidateArticleInputJob;
use App\Domains\Article\Jobs\AuthoriseArticleUpdateJob;
class UpdateArticleFeature extends Feature
{
/**
* @var int
*/
private $articleId;
/**
* CreateArticleFeature constructor.
*
* @param int $articleId
*/
public function __construct(int $articleId)
{
$this->articleId = $articleId;
}
/**
* @param Request $request
*/
public function handle(Request $request)
{
$this->run(new ValidateArticleInputJob($request->input()));
$this->run(AuthoriseArticleUpdateJob::class, [
'user' => Auth::user(),
'articleId' => $this->articleId,
]);
$isSuccess = $this->run(SaveArticleJob::class, [
'articleId' => $this->articleId,
'title'=> $request->input('title'),
'content' => $request->input('content'),
]);
$this->run(LogActivityJob::class, [
'userId' => Auth::user()->id,
'description' => "Article updated ".$this->articleId,
]);
$this->run(NotifyFollowersOperation::class, [
'userId' => Auth::user()->id,
]);
return $this->run(new RespondWithJsonJob(['success' => $isSuccess]));
}
}
tests/Features/UpdateArticleFeatureTest.php
namespace App\Tests\Features;
use Tests\TestCase;
use App\Data\Models\User;
use App\Data\Models\Article;
use App\Features\UpdateArticleFeature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UpdateArticleFeatureTest extends TestCase
{
use RefreshDatabase;
use DatabaseMigrations;
public function test_updating_article_feature()
{
$article = factory(Article::class)->create();
$user = $article->user;
$this->actingAs($user)->put("/articles/$article->id", [
'title' => 'My Updated New Title',
'content' => 'Shiny shiny, shiny new content.',
])->assertJson([
'status'=> 200,
'data' => [
'success' => true,
]
]);
$retrieved = Article::find($article->id);
$this->assertEquals('My Updated New Title', $retrieved->title);
$this->assertEquals('Shiny shiny, shiny new content.', $retrieved->content);
}
/**
* @expectedException \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function test_updating_article_fails_when_unauthorised()
{
$article = factory(Article::class)->create();
$anotherUser = factory(User::class)->create();
$this->actingAs($anotherUser)->put("/articles/$article->id", [
'title' => 'My Updated New Title',
'content' => 'Shiny shiny, shiny new content.',
])->assertJson([
'status'=> 200,
'data' => [
'success' => true,
]
]);
}
}
Notable Observation
Through this workflow, it is important to note how tests cover the scope of the component only and keeps us from breaking the boundaries where we shouldn't. Especially when it comes to the feature test where it is kept to what it is responsible for, without having to repeat the tests that's been covered by the jobts themselves. However, it might be important to test edge cases at the feature level to ensure that the responses from our backend are consistent and as expected.
FAQ
Why sometimes we use UserId and other times the whole User?
Because of the scope. For example: AuthoriseArticleUpdateJob might require more information about the user in the future than their ID because the nature of what it does - authorisation. While other jobs such as LogActivityJob and NotifyFollowersOperation are only concerned about the user's identifier, regardless of where and how this was retrieved.
Doing this allows for the reuse of these jobs beyond the scope of this feature alone. We can use them anywhere else (for example from a command where users aren't authenticated) without having to workaround authentication where it doesn't belong.
Could we have improved logging activity?
Yes. For example, create a job for each activity (i.e. LogArticleUpdateActivityJob instead of a generic LogActivityJob. It would be preferred over having a string in the feature which is recommended to be avoided when possible but kept here for the simplicity of this exercise.
Can we add more safety to this code?
Yes, plenty! A simple addition would be to ensure there's a session before proceeding with the feature to avoid errors such as:
Symfony\Component\Debug\Exception\FatalThrowableError: Argument 1 passed to
App\Domains\Article\Jobs\AuthoriseArticleUpdateJob::__construct()
must be an instance of App\Data\Models\User, null given
This could be done around the HTTP Kernel like in the middleware area.
Another safety check would be to wrap async calls with a try/catch block so that failures in there don't affect the user's response (namely LogActivityJob and NotifyFollowersOperation because they do not dictate the actual status of the update.