We should add a way for a user to be able to delete their thread from the database if they choose to do so. This tutorial will have us doing just that. First off we’ll set up a test or two to help us ensure the delete function works as intended. Next up, we’ll learn a little bit about sending response codes which is helpful when dealing with scenarios where the back end is acting as an API. Another hot topic will be learning about how to delete related models when deleting a given record. When we delete a thread, we also want to consider the relationship to replies and delete associated replies as well. Last up we’ll take a quick look at deleting with model events and setting up the link the UI to actually trigger the delete.
Thread Can Be Deleted Test
In the Feature test directory we already have a class that deals with Threads. We can add a test called test_a_thread_can_be_deleted() to the CreateThreadsTest class to get started.
What will our test do? Here is the pseudocode.
- Given a user is signed in
- Given there is a thread
- When the user submits a json request to delete the thread
- Then the thread should no longer exist in the database
With those steps in mind, here is our first stab at creating the code for this test.
public function test_a_thread_can_be_deleted()
{
$this->withoutExceptionHandling()->signIn();
$thread = create('AppThread');
$this->json('DELETE', $thread->path());
$this->assertDatabaseMissing('threads', $thread->toArray());
}
Running the test_a_thread_can_be_deleted() test gives us a MethodNotAllowedHttpException which we know means we are missing a route.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_thread_can_be_deleted PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 993 ms, Memory: 8.00MB There was 1 error: 1) TestsFeatureCreateThreadsTest::test_a_thread_can_be_deleted SymfonyComponentHttpKernelExceptionMethodNotAllowedHttpException:
As such, let’s open up our routes file and get to fixing that right away. Below is the new route highlighted for us.
<?php
Route::get('/', function () {
return view('welcome');
});
Auth::routes();
Route::get('/home', 'HomeController@index')->name('home');
Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::delete('/threads/{channel}/{thread}', 'ThreadsController@destroy');
Route::post('/threads', 'ThreadsController@store');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
Route::post('/replies/{reply}/favorites', 'FavoritesController@store');
Route::get('/profiles/{user}', 'ProfilesController@show')->name('profile');
We can run the test again, but now we find a different error.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_thread_can_be_deleted PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 1.04 seconds, Memory: 10.00MB There was 1 error: 1) TestsFeatureCreateThreadsTest::test_a_thread_can_be_deleted SymfonyComponentDebugExceptionFatalThrowableError: Type error: Argument 1 passed to AppHttpControllersThreadsController::destroy() must be an instance of AppThread, string given
We are getting a fatal throwable type error. Well, let’s look at the destroy method in the ThreadsController and see what we have.
public function destroy(Thread $thread)
{
//
}
Of course this test would fail. We only have a method stub for destroy(). We need to update that method so it actually deletes a thread.
public function destroy($channel, Thread $thread)
{
$thread->delete();
}
Let’s run the test again to see the result.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_thread_can_be_deleted PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 878 ms, Memory: 10.00MB There was 1 error: 1) TestsFeatureCreateThreadsTest::test_a_thread_can_be_deleted IlluminateDatabaseQueryException: SQLSTATE[HY000]: General error: 25 bind or column index out of range (SQL: select count(*) as aggregate from "threads" where ("user_id" = 2 and "channel_id" = 1 and "title" = Ea maxime et omnis. and "body" = Id velit sequi nulla est officiis sed. Laborum sit quas velit aliquid. Fugit culpa rerum enim. Quos dicta ut est maiores. and "updated_at" = 2018-01-24 23:02:50 and "created_at" = 2018-01-24 23:02:50 and "id" = 1 and "channel" = 1))
This actually might be an issue in the test code. Let’s change the test to this:
public function test_a_thread_can_be_deleted()
{
$this->withoutExceptionHandling()->signIn();
$thread = create('AppThread');
$this->json('DELETE', $thread->path());
$this->assertDatabaseMissing('threads', ['id' => $thread->id]);
}
Running the test one more time gives us a green for passing, so it looks like that change fixed us up.
Setting Response Code
When data is changed on the server as a result of a request sent to it, it makes sense to return a status code to indicate the result of that request. We can update the test and the destroy() method to facilitate this idea.
public function test_a_thread_can_be_deleted()
{
$this->withoutExceptionHandling()->signIn();
$thread = create('AppThread');
$response = $this->json('DELETE', $thread->path());
$response->assertStatus(204);
$this->assertDatabaseMissing('threads', ['id' => $thread->id]);
}
public function destroy($channel, Thread $thread)
{
$thread->delete();
return response([], 204);
}
How To Delete Related Models
Threads may have replies. If a user deletes a thread, but that thread has replies associated with it, well then that is a problem at this point. Right now, those replies will be left in the database like phantom records. You don’t want that, so we need to look at how to delete related replies to any given thread. The first option is to simply reference the relationship in the destroy() method, and make a call to delete:
public function destroy($channel, Thread $thread)
{
$thread->replies()->delete();
$thread->delete();
return response([], 204);
}
Since we are now deleting associated replies, let’s also update our test to reflect that.
public function test_a_thread_can_be_deleted()
{
$this->withoutExceptionHandling()->signIn();
$thread = create('AppThread');
$reply = create('AppReply', ['thread_id', $thread->id]);
$response = $this->json('DELETE', $thread->path());
$response->assertStatus(204);
$this->assertDatabaseMissing('threads', ['id' => $thread->id]);
$this->assertDatabaseMissing('replies', ['id' => $reply->id]);
}
That ought to do it, let’s run the test.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_thread_can_be_deleted PHPUnit 6.5.5 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 1.11 seconds, Memory: 8.00MB There was 1 error: 1) TestsFeatureCreateThreadsTest::test_a_thread_can_be_deleted InvalidArgumentException: A four digit year could not be found Data missing
What the heck? That is a strange error. “InvalidArgumentException: A four digit year could not be found Data missing”. Ok, it looks like the wrong argument was passed to the create() method for the model factory. create(‘AppReply’, [‘thread_id’, $thread->id]) should be changed to create(‘AppReply’, [‘thread_id’ => $thread->id]). Here is the correct test with the lines highlighted which deal with related model relationships, and when we run the test it does pass. Yes!
public function test_a_thread_can_be_deleted()
{
$this->withoutExceptionHandling()->signIn();
$thread = create('AppThread');
$reply = create('AppReply', ['thread_id' => $thread->id]);
$response = $this->json('DELETE', $thread->path());
$response->assertStatus(204);
$this->assertDatabaseMissing('threads', ['id' => $thread->id]);
$this->assertDatabaseMissing('replies', ['id' => $reply->id]);
}
Deleting With Model Events
As Eloquent is interacting with the database, it is triggering various events during the lifecycle of the interaction. We can hook into those events and take various actions if we like. In fact we can use Model Events to actually listen for a thread delete, and then delete associated replies as well. Go ahead and open up the Thread.php model, and we can look at how to set this up.
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
class Thread extends Model
{
protected $guarded = [];
protected $with = ['creator', 'channel'];
protected static function boot()
{
parent::boot();
static::addGlobalScope('replyCount', function ($builder) {
$builder->withCount('replies');
});
static::deleting(function ($thread) {
$thread->replies()->delete();
});
}
public function path()
{
return '/threads/' . $this->channel->slug . '/' . $this->id;
}
public function replies()
{
return $this->hasMany(Reply::class);
//->withCount('favorites');
//->with('owner');
}
public function creator()
{
return $this->belongsTo(User::class, 'user_id');
}
public function channel()
{
return $this->belongsTo(Channel::class);
}
public function addReply($reply)
{
$this->replies()->create($reply);
}
public function scopeFilter($query, $filters)
{
return $filters->apply($query);
}
}
In the code above, we see the highlighted snippet inside of the boot() method on the Model. This is basically tapping into those model events for us. It is saying, when the thread is in the process of being deleted, go ahead and also delete all of the replies. Pretty cool, right? Now you can remove the call to $thread->replies()->delete() from the destroy() method on the ThreadsController and everything should still work. Running our test shows us that it is still working. Nice.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_a_thread_can_be_deleted PHPUnit 6.5.5 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 828 ms, Memory: 10.00MB OK (1 test, 3 assertions)
Guests can not delete threads
Only logged in users should be able to delete their thread. We don’t want random visitors of the application to be able to just browse a thread, click a link, and delete the thread. We can set up a test to support this feature. Once again we try to follow the Given, When, Then format of setting up a test.
- Given there is a guest
- Given there is a thread
- When a guest submits a json request to delete the thread
- Then the response should be a redirect
The test may look like so:
public function test_guests_can_not_delete_threads() { $thread = create('AppThread'); $response = $this->json('DELETE', $thread->path()); $response->assertRedirect('/'); }
Upon running the test, we get an error of “Response status code [401] is not a redirect status code.” Ok, this is because the request in the test is a json request and not a standard request.
vagrant@homestead:~/Code/forumio$ phpunit --filter test_guests_can_not_delete_threads PHPUnit 6.5.5 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 993 ms, Memory: 10.00MB There was 1 failure: 1) TestsFeatureCreateThreadsTest::test_guests_can_not_delete_threads Response status code [401] is not a redirect status code. Failed asserting that false is true. /home/vagrant/Code/forumio/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:93 /home/vagrant/Code/forumio/tests/Feature/CreateThreadsTest.php:61 FAILURES! Tests: 1, Assertions: 1, Failures: 1.
To fix this, let’s update the test like so:
public function test_guests_can_not_delete_threads() { $thread = create('AppThread'); $response = $this->delete($thread->path()); $response->assertRedirect('/login'); }
Run the test, see it passing, and be assured that guests can not delete a thread they shouldn’t be able to. Nice.
Configure A Link To Delete A Model
The plumbing is in place for threads to be deleted, however the user interface needs an actual link or button the user can click to send the delete request to the server. We can easily add this in threads/show.blade.php. We’ll re arrange a few things to handle the layout properly, but the key markup is highlighted.
<div class="panel-heading">
<div class="level">
<span class="flex">
<a href="{{ route('profile', $thread->creator) }}">{{ $thread->creator->name }}</a> posted:
{{ $thread->title }}
</span>
<form action="{{$thread->path()}}" method="POST">
{{ csrf_field() }}
{{ method_field('DELETE') }}
<button class="btn btn-link">Delete Thread</button>
</form>
</div>
</div>
There are a couple of things to note here. This is actually a form, which submits a post request to the endpoint of the thread in question. The styling of the button simply makes it look like a link in the browser. Since we are dealing with a form submission, we must include the call to csrf_field() to generate a Cross-site request forgery Token to protect the session. You may also notice the call to method_field(‘DELETE’). This is to inform Laravel that the intention is to actually delete something from the server.
Redirecting Upon Deletion
One last thing we want to set up is to redirect the user somewhere after deleting a thread. In this case we will redirect to the main threads page. Let’s update the destroy() method on the ThreadsController like so:
public function destroy($channel, Thread $thread)
{
$thread->delete();
if (request()->wantsJson()) {
return response([], 204);
}
return redirect('/threads');
}
Getting Ready To Delete A Thread
Successful redirect after deleting the model
How To Delete A Record From The Database Summary
We hit all the topics we needed to cover in this tutorial with regard to setting up a user to delete a thread from the database. From creating the supporting tests right through to creating the link to send the delete request from the UI, we got it covered. Nice.