Back to Course |
[NEW] Laravel Project: From Start to Finish

Second "Issues" CRUD: Repeating the Processes

Now that we have all of our tools in place, let's practice them all:

  • Create a GitHub issue
  • Create a new feature branch
  • Make code changes
  • Write tests for the changes
  • Run pint/larastan
  • Commit the changes

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!


Creating GitHub Issue

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.


Creating New Feature Branch

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

Coding the Feature

While writing code locally, you should remember the good "habits" for local checks/processes:

  • Code styling according to Pint
  • Following the rules for Larastan

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

Writing Tests

Again, tests are essential to ensure the feature works as expected. So let's write some:

  • CRUD Tests - To make sure the feature works as expected
  • Validation Tests - To prevent invalid data from being saved
  • Job Test - To make sure the job works as expected
  • Tenancy Test - To prevent data from leaking between tenants

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.


Running Pint/Larastan

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.


Committing the Changes

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.