Now that we have all of our tools in place, let's practice them all:
The end result should be a PR that passes all the checks and is ready to be merged:
Only with practice will you get better at using these tools. So let's get started!
Like any task, it should start with an issue we have to solve:
This is what we are going to expect:
With this, we can start implementing the feature.
Remember, branches are essential to keep your code organized:
Note: Don't forget to checkout to the dev
branch before creating a new feature branch.
git checkout -b feature/issues-crud
While writing code locally, you should remember the good "habits" for local checks/processes:
So let's look at what we coded in this.
Note: This is just a quick overview of the changes. In this course, we are still focusing on the workflow, not on the code.
app/Http/Controllers/IssueController.php
class IssueController extends Controller{ public function index(): View { $issues = Issue::query() ->where('user_id', auth()->id()) ->withCount('links') ->orderBy('id', 'desc') ->paginate(20); $availableLinks = Link::query() ->where('user_id', auth()->id()) ->where('issue_id', null) ->count(); return view('issues.index', [ 'issues' => $issues, 'availableLinks' => $availableLinks, ]); } public function create(): View { $links = Link::query() ->where('user_id', auth()->id()) ->where('issue_id', null) ->get(); return view('issues.create', [ 'links' => $links, ]); } public function store(StoreIssueRequest $request): RedirectResponse { $issue = Issue::create($request->validated() + [ 'user_id' => auth()->id(), ]); Link::query() ->where('user_id', auth()->id()) ->where('issue_id', null) ->update(['issue_id' => $issue->id]); dispatch(new GenerateIssueHtmlJob($issue->id)); return redirect()->route('issues.index') ->with('message', 'Issue created successfully.'); }}
In our List, we have:
resources/views/issues/index.blade.php
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('Issues List') }} </h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 bg-white border-b border-gray-200"> @if (session('message')) <div class="mb-4 font-medium text-sm text-green-600"> {{ session('message') }} </div> @endif <div class="mt-4"> @if($availableLinks) <a href="{{ route('issues.create') }}" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">Add New Issue</a> @else <p class="text-red-500">No links available to create a new issue</p> @endif </div> <table class="table-auto w-full mt-4 border"> <thead> <tr> <th class="px-4 py-2">Subject</th> <th class="px-4 py-2">Links</th> <th class="px-4 py-2">Created At</th> <th class="px-4 py-2">Sent At</th> </tr> </thead> <tbody> @foreach ($issues as $issue) <tr> <td class="border px-4 py-2">{{ $issue->subject }}</td> <td class="border px-4 py-2">{{ $issue->links_count }}</td> <td class="border px-4 py-2">{{ $issue->created_at->format('Y-m-d') }}</td> <td class="border px-4 py-2">{{ $issue->sent_at?->format('Y-m-d') ?? 'Not Sent'}}</td> </tr> @endforeach </tbody> </table> <div class="mt-4"> {{ $issues->links() }} </div> </div> </div> </div> </div></x-app-layout>
And the one Job we created:
app/Jobs/GenerateIssueHtmlJob.php
class GenerateIssueHtmlJob implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; private Issue $issue; public function __construct(public int $issueId) { $this->issue = Issue::with('links')->findOrFail($this->issueId); } public function handle(): void { $html = view('issues.components.issueHtml', [ 'issue' => $this->issue, ]); $this->issue->links_html = $html->render(); $this->issue->save(); }}
With a simple view:
resources/views/issues/components/issueHtml.blade.php
@if($issue->header_text) <p>{{ $issue->header_text }}</p>@endif<ul> @foreach($issue->links as $link) <li> <a href="{{ $link->url }}">{{ $link->title }}</a> </li> @endforeach</ul>@if($issue->footer_text) <p>{{ $issue->footer_text }}</p>@endif
Again, tests are essential to ensure the feature works as expected. So let's write some:
Let's start writing the CRUD tests:
tests/Feature/Issues/IssuesCrudTest.php
use App\Models\Issue;use App\Models\Link;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase; use function Pest\Laravel\actingAs;use function Pest\Laravel\get;use function Pest\Laravel\post; uses(RefreshDatabase::class); test('can create issues', function () { $user = User::factory()->create(); $links = Link::factory()->count(3)->create(['user_id' => $user->id]); actingAs($user); // Check if the create issue page is accessible get(route('issues.create')) ->assertStatus(200) ->assertSee('Create Issue') ->assertSee('Subject') ->assertSee('Header Text') ->assertSee('Links to be Included') ->assertSee($links->pluck('title')->toArray()) ->assertSee('Footer Text'); // Create an issue post(route('issues.store'), [ 'subject' => 'Test Issue', 'header_text' => 'This is a test issue', 'footer_text' => 'This is a test issue footer', ]) ->assertStatus(302) ->assertRedirect(route('issues.index')) ->assertSessionHas('message', 'Issue created successfully.'); // Check if the issue is created $this->assertDatabaseHas('issues', [ 'subject' => 'Test Issue', 'header_text' => 'This is a test issue', 'footer_text' => 'This is a test issue footer', ]); // Check if the links are attached to the issue $issue = Issue::where('subject', 'Test Issue')->first(); $this->assertEquals($links->pluck('id')->toArray(), $issue->links->pluck('id')->toArray());}); test('can list issues', function () { $user = User::factory()->create(); $issues = Issue::factory()->count(3)->create(['user_id' => $user->id]); actingAs($user); // Check if the issues list page is accessible get(route('issues.index')) ->assertStatus(200) ->assertSee('Issues') ->assertSee($issues->pluck('subject')->toArray());}); test('can see pagination on list', function () { $user = User::factory()->create(); $issues = Issue::factory()->count(100)->create(['user_id' => $user->id]); actingAs($user); // Check if the issues list page is accessible get(route('issues.index')) ->assertStatus(200) ->assertSeeText('Next');}); test('issue create button visibility toggles based on free links count', function () { $user = User::factory()->create(); actingAs($user); // Create an issue without selecting any links get(route('issues.index')) ->assertStatus(200) ->assertDontSee('Add New Issue'); // Create a link $link = Link::factory()->create(['user_id' => $user->id]); // Check if the create issue button is visible get(route('issues.index')) ->assertStatus(200) ->assertSee('Add New Issue');});
As you can see, we skipped the edit
and delete
tests since our GitHub issue only requested the create
and list
features.
Now, let's make sure our Validation works:
tests/Feature/Issues/IssuesValidationTest.php
use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase; use function Pest\Laravel\actingAs;use function Pest\Laravel\post; uses(RefreshDatabase::class); test('validates issue subject', function () { $user = User::factory()->create(); actingAs($user); post(route('issues.store'), [ 'subject' => '', ]) ->assertStatus(302) ->assertSessionHasErrors(['subject' => 'The subject field is required.']);});
Next, we need to test our Job:
tests/Feature/Issues/JobTest.php
use App\Jobs\GenerateIssueHtmlJob;use App\Models\Issue;use App\Models\Link;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase; use function Pest\Laravel\actingAs;use function Pest\Laravel\post;use function PHPUnit\Framework\assertStringContainsString; uses(RefreshDatabase::class); test('job generates correct html', function () { $issue = Issue::factory() ->has(Link::factory()->count(2)) ->create([ 'header_text' => 'Header text', 'footer_text' => 'Footer text', ]); (new GenerateIssueHtmlJob($issue->id))->handle(); $issue = $issue->fresh(['links']); assertStringContainsString('Header text', $issue->links_html); assertStringContainsString('Footer text', $issue->links_html); assertStringContainsString($issue->links[0]->title, $issue->links_html); assertStringContainsString($issue->links[1]->title, $issue->links_html); assertStringContainsString($issue->links[0]->url, $issue->links_html); assertStringContainsString($issue->links[1]->url, $issue->links_html);}); test('job gets called when issue is created', function () { Queue::fake(); $user = User::factory()->create(); actingAs($user); post(route('issues.store'), [ 'subject' => 'Test subject', ]) ->assertStatus(302) ->assertSessionHas('message', 'Issue created successfully.'); Queue::assertPushed(GenerateIssueHtmlJob::class);});
And finally, let's test our Tenancy:
tests/Feature/Issues/TenancyTest.php
use App\Models\Issue;use App\Models\Link;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase; use function Pest\Laravel\actingAs;use function Pest\Laravel\get; uses(RefreshDatabase::class); test('users cant see others issues', function () { $user = User::factory()->create(); $issue = Issue::factory()->create(['subject' => 'My Issue', 'user_id' => $user->id]); $anotherUser = User::factory()->create(); $otherUsersIssues = Issue::factory()->count(3)->create([ 'user_id' => $anotherUser->id, 'subject' => 'Another Issue', ]); actingAs($user); get(route('issues.index')) ->assertStatus(200) ->assertSeeText($issue->subject) ->assertDontSeeText($otherUsersIssues->pluck('subject')->toArray());}); test('users cant see others links when creating an issue', function () { $user = User::factory()->create(); $userLinks = Link::factory()->create([ 'user_id' => $user->id, 'title' => 'My Link', ]); $anotherUser = User::factory()->create(); $anotherUserLinks = Link::factory()->count(3)->create([ 'user_id' => $anotherUser->id, 'title' => 'Another Link', ]); actingAs($user); get(route('issues.create')) ->assertStatus(200) ->assertSeeText($userLinks->title) ->assertDontSeeText($anotherUserLinks->pluck('title')->toArray());});
That's it. Our feature is now complete and has been tested.
At this point, we want to remind you that you should run the code quality tools locally before committing the code. So let's run them:
./vendor/bin/pint./vendor/bin/larastan analyse
If you see any issues, fix them before moving forward. Otherwise, your GitHub Actions will fail.
Now it's time to commit the changes:
Note: Remember to add the issue number to the commit message.
git add .git commit -m "Adding Issues CRUD - resolves #14"git push origin feature/issues-crud
That's it. We can now open a PR and wait for the checks to pass:
Nice, huh? With all the automation we had set up in the previous lessons, from here you will see the benefits for the rest of your application lifetime.
And, of course, our tests are passing:
Now we can merge it into the dev
branch:
That's it! We have followed the whole process, and now we have a new feature in our application with all the checks in place.