diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index ccccd95f4..11f89fc34 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -1288,6 +1288,7 @@ class EntityRepo
      * Returns the number of books that had permissions updated.
      * @param Bookshelf $bookshelf
      * @return int
+     * @throws \Throwable
      */
     public function copyBookshelfPermissions(Bookshelf $bookshelf)
     {
diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php
index a86a1cdfc..fb09841cf 100644
--- a/resources/lang/en/errors.php
+++ b/resources/lang/en/errors.php
@@ -49,6 +49,7 @@ return [
 
     // Entities
     'entity_not_found' => 'Entity not found',
+    'bookshelf_not_found' => 'Bookshelf not found',
     'book_not_found' => 'Book not found',
     'page_not_found' => 'Page not found',
     'chapter_not_found' => 'Chapter not found',
diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php
new file mode 100644
index 000000000..9071e3c06
--- /dev/null
+++ b/tests/Entity/BookShelfTest.php
@@ -0,0 +1,170 @@
+<?php namespace Tests;
+
+use BookStack\Book;
+use BookStack\Bookshelf;
+
+class BookShelfTest extends TestCase
+{
+
+    public function test_shelves_shows_in_header_if_have_view_permissions()
+    {
+        $viewer = $this->getViewer();
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementContains('header', 'Shelves');
+
+        $viewer->roles()->delete();
+        $this->giveUserPermissions($viewer);
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementNotContains('header', 'Shelves');
+
+        $this->giveUserPermissions($viewer, ['bookshelf-view-all']);
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementContains('header', 'Shelves');
+
+        $viewer->roles()->delete();
+        $this->giveUserPermissions($viewer, ['bookshelf-view-own']);
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementContains('header', 'Shelves');
+    }
+
+    public function test_shelves_page_contains_create_link()
+    {
+        $resp = $this->asEditor()->get('/shelves');
+        $resp->assertElementContains('a', 'Create New Shelf');
+    }
+
+    public function test_shelves_create()
+    {
+        $booksToInclude = Book::take(2)->get();
+        $shelfInfo = [
+            'name' => 'My test book' . str_random(4),
+            'description' => 'Test book description ' . str_random(10)
+        ];
+        $resp = $this->asEditor()->post('/shelves', array_merge($shelfInfo, [
+            'books' => $booksToInclude->implode('id', ','),
+            'tags' => [
+                [
+                    'name' => 'Test Category',
+                    'value' => 'Test Tag Value',
+                ]
+            ],
+        ]));
+        $resp->assertRedirect();
+        $editorId = $this->getEditor()->id;
+        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId]));
+
+        $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first();
+        $shelfPage = $this->get($shelf->getUrl());
+        $shelfPage->assertSee($shelfInfo['name']);
+        $shelfPage->assertSee($shelfInfo['description']);
+        $shelfPage->assertElementContains('.tag-item', 'Test Category');
+        $shelfPage->assertElementContains('.tag-item', 'Test Tag Value');
+
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]);
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]);
+    }
+
+    public function test_shelf_view()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertStatus(200);
+        $resp->assertSeeText($shelf->name);
+        $resp->assertSeeText($shelf->description);
+
+        foreach ($shelf->books as $book) {
+            $resp->assertSee($book->name);
+        }
+    }
+
+    public function test_shelf_view_shows_action_buttons()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asAdmin()->get($shelf->getUrl());
+        $resp->assertSee($shelf->getUrl('/edit'));
+        $resp->assertSee($shelf->getUrl('/permissions'));
+        $resp->assertSee($shelf->getUrl('/delete'));
+        $resp->assertElementContains('a', 'Edit');
+        $resp->assertElementContains('a', 'Permissions');
+        $resp->assertElementContains('a', 'Delete');
+
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertDontSee($shelf->getUrl('/permissions'));
+    }
+
+    public function test_shelf_edit()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asEditor()->get($shelf->getUrl('/edit'));
+        $resp->assertSeeText('Edit Bookshelf');
+
+        $booksToInclude = Book::take(2)->get();
+        $shelfInfo = [
+            'name' => 'My test book' . str_random(4),
+            'description' => 'Test book description ' . str_random(10)
+        ];
+
+        $resp = $this->asEditor()->put($shelf->getUrl(), array_merge($shelfInfo, [
+            'books' => $booksToInclude->implode('id', ','),
+            'tags' => [
+                [
+                    'name' => 'Test Category',
+                    'value' => 'Test Tag Value',
+                ]
+            ],
+        ]));
+        $shelf = Bookshelf::find($shelf->id);
+        $resp->assertRedirect($shelf->getUrl());
+        $this->assertSessionHas('success');
+
+        $editorId = $this->getEditor()->id;
+        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId]));
+
+        $shelfPage = $this->get($shelf->getUrl());
+        $shelfPage->assertSee($shelfInfo['name']);
+        $shelfPage->assertSee($shelfInfo['description']);
+        $shelfPage->assertElementContains('.tag-item', 'Test Category');
+        $shelfPage->assertElementContains('.tag-item', 'Test Tag Value');
+
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]);
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]);
+    }
+
+    public function test_shelf_delete()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asEditor()->get($shelf->getUrl('/delete'));
+        $resp->assertSeeText('Delete Bookshelf');
+        $resp->assertSee("action=\"{$shelf->getUrl()}\"");
+
+        $resp = $this->delete($shelf->getUrl());
+        $resp->assertRedirect('/shelves');
+        $this->assertDatabaseMissing('bookshelves', ['id' => $shelf->id]);
+        $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
+        $this->assertSessionHas('success');
+    }
+
+    public function test_shelf_copy_permissions()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asAdmin()->get($shelf->getUrl('/permissions'));
+        $resp->assertSeeText('Copy Permissions');
+        $resp->assertSee("action=\"{$shelf->getUrl('/copy-permissions')}\"");
+
+        $child = $shelf->books()->first();
+        $editorRole = $this->getEditor()->roles()->first();
+        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
+        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
+
+        $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
+        $resp = $this->post($shelf->getUrl('/copy-permissions'));
+        $child = $shelf->books()->first();
+
+        $resp->assertRedirect($shelf->getUrl());
+        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
+        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
+    }
+
+}
diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php
index 2bbb1a5fa..540125fd1 100644
--- a/tests/Permissions/RestrictionsTest.php
+++ b/tests/Permissions/RestrictionsTest.php
@@ -1,6 +1,7 @@
 <?php namespace Tests;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Entity;
 use BookStack\User;
 use BookStack\Repos\EntityRepo;
@@ -34,6 +35,63 @@ class RestrictionsTest extends BrowserKitTest
         parent::setEntityRestrictions($entity, $actions, $roles);
     }
 
+    public function test_bookshelf_view_restriction()
+    {
+        $shelf = Bookshelf::first();
+
+        $this->actingAs($this->user)
+            ->visit($shelf->getUrl())
+            ->seePageIs($shelf->getUrl());
+
+        $this->setEntityRestrictions($shelf, []);
+
+        $this->forceVisit($shelf->getUrl())
+            ->see('Bookshelf not found');
+
+        $this->setEntityRestrictions($shelf, ['view']);
+
+        $this->visit($shelf->getUrl())
+            ->see($shelf->name);
+    }
+
+    public function test_bookshelf_update_restriction()
+    {
+        $shelf = BookShelf::first();
+
+        $this->actingAs($this->user)
+            ->visit($shelf->getUrl('/edit'))
+            ->see('Edit Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->forceVisit($shelf->getUrl('/edit'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->visit($shelf->getUrl('/edit'))
+            ->seePageIs($shelf->getUrl('/edit'));
+    }
+
+    public function test_bookshelf_delete_restriction()
+    {
+        $shelf = Book::first();
+
+        $this->actingAs($this->user)
+            ->visit($shelf->getUrl('/delete'))
+            ->see('Delete Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->forceVisit($shelf->getUrl('/delete'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->visit($shelf->getUrl('/delete'))
+            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+    }
+
     public function test_book_view_restriction()
     {
         $book = Book::first();
@@ -325,6 +383,23 @@ class RestrictionsTest extends BrowserKitTest
             ->seePageIs($pageUrl . '/delete')->see('Delete Page');
     }
 
+    public function test_bookshelf_restriction_form()
+    {
+        $shelf = Bookshelf::first();
+        $this->asAdmin()->visit($shelf->getUrl('/permissions'))
+            ->see('Bookshelf Permissions')
+            ->check('restricted')
+            ->check('restrictions[2][view]')
+            ->press('Save Permissions')
+            ->seeInDatabase('bookshelves', ['id' => $shelf->id, 'restricted' => true])
+            ->seeInDatabase('entity_permissions', [
+                'restrictable_id' => $shelf->id,
+                'restrictable_type' => 'BookStack\Bookshelf',
+                'role_id' => '2',
+                'action' => 'view'
+            ]);
+    }
+
     public function test_book_restriction_form()
     {
         $book = Book::first();
@@ -413,6 +488,44 @@ class RestrictionsTest extends BrowserKitTest
             ->dontSee($page->name);
     }
 
+    public function test_bookshelf_update_restriction_override()
+    {
+        $shelf = Bookshelf::first();
+
+        $this->actingAs($this->viewer)
+            ->visit($shelf->getUrl('/edit'))
+            ->dontSee('Edit Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->forceVisit($shelf->getUrl('/edit'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->visit($shelf->getUrl('/edit'))
+            ->seePageIs($shelf->getUrl('/edit'));
+    }
+
+    public function test_bookshelf_delete_restriction_override()
+    {
+        $shelf = Bookshelf::first();
+
+        $this->actingAs($this->viewer)
+            ->visit($shelf->getUrl('/delete'))
+            ->dontSee('Delete Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->forceVisit($shelf->getUrl('/delete'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->visit($shelf->getUrl('/delete'))
+            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+    }
+
     public function test_book_create_restriction_override()
     {
         $book = Book::first();
diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php
index f076e6734..e0f827d02 100644
--- a/tests/Permissions/RolesTest.php
+++ b/tests/Permissions/RolesTest.php
@@ -1,5 +1,6 @@
 <?php namespace Tests;
 
+use BookStack\Bookshelf;
 use BookStack\Page;
 use BookStack\Repos\PermissionsRepo;
 use BookStack\Role;
@@ -16,32 +17,6 @@ class RolesTest extends BrowserKitTest
         $this->user = $this->getViewer();
     }
 
-    /**
-     * Give the given user some permissions.
-     * @param \BookStack\User $user
-     * @param array $permissions
-     */
-    protected function giveUserPermissions(\BookStack\User $user, $permissions = [])
-    {
-        $newRole = $this->createNewRole($permissions);
-        $user->attachRole($newRole);
-        $user->load('roles');
-        $user->permissions(false);
-    }
-
-    /**
-     * Create a new basic role for testing purposes.
-     * @param array $permissions
-     * @return Role
-     */
-    protected function createNewRole($permissions = [])
-    {
-        $permissionRepo = app(PermissionsRepo::class);
-        $roleData = factory(\BookStack\Role::class)->make()->toArray();
-        $roleData['permissions'] = array_flip($permissions);
-        return $permissionRepo->saveNewRole($roleData);
-    }
-
     public function test_admin_can_see_settings()
     {
         $this->asAdmin()->visit('/settings')->see('Settings');
@@ -203,6 +178,90 @@ class RolesTest extends BrowserKitTest
         }
     }
 
+    public function test_bookshelves_create_all_permissions()
+    {
+        $this->checkAccessPermission('bookshelf-create-all', [
+            '/create-shelf'
+        ], [
+            '/shelves' => 'Create New Shelf'
+        ]);
+
+        $this->visit('/create-shelf')
+            ->type('test shelf', 'name')
+            ->type('shelf desc', 'description')
+            ->press('Save Shelf')
+            ->seePageIs('/shelves/test-shelf');
+    }
+
+    public function test_bookshelves_edit_own_permission()
+    {
+        $otherShelf = Bookshelf::first();
+        $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
+        $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
+        $this->regenEntityPermissions($ownShelf);
+
+        $this->checkAccessPermission('bookshelf-update-own', [
+            $ownShelf->getUrl('/edit')
+        ], [
+            $ownShelf->getUrl() => 'Edit'
+        ]);
+
+        $this->visit($otherShelf->getUrl())
+            ->dontSeeInElement('.action-buttons', 'Edit')
+            ->visit($otherShelf->getUrl('/edit'))
+            ->seePageIs('/');
+    }
+
+    public function test_bookshelves_edit_all_permission()
+    {
+        $otherShelf = \BookStack\Bookshelf::first();
+        $this->checkAccessPermission('bookshelf-update-all', [
+            $otherShelf->getUrl('/edit')
+        ], [
+            $otherShelf->getUrl() => 'Edit'
+        ]);
+    }
+
+    public function test_bookshelves_delete_own_permission()
+    {
+        $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
+        $otherShelf = \BookStack\Bookshelf::first();
+        $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
+        $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
+        $this->regenEntityPermissions($ownShelf);
+
+        $this->checkAccessPermission('bookshelf-delete-own', [
+            $ownShelf->getUrl('/delete')
+        ], [
+            $ownShelf->getUrl() => 'Delete'
+        ]);
+
+        $this->visit($otherShelf->getUrl())
+            ->dontSeeInElement('.action-buttons', 'Delete')
+            ->visit($otherShelf->getUrl('/delete'))
+            ->seePageIs('/');
+        $this->visit($ownShelf->getUrl())->visit($ownShelf->getUrl('/delete'))
+            ->press('Confirm')
+            ->seePageIs('/shelves')
+            ->dontSee($ownShelf->name);
+    }
+
+    public function test_bookshelves_delete_all_permission()
+    {
+        $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
+        $otherShelf = \BookStack\Bookshelf::first();
+        $this->checkAccessPermission('bookshelf-delete-all', [
+            $otherShelf->getUrl('/delete')
+        ], [
+            $otherShelf->getUrl() => 'Delete'
+        ]);
+
+        $this->visit($otherShelf->getUrl())->visit($otherShelf->getUrl('/delete'))
+            ->press('Confirm')
+            ->seePageIs('/shelves')
+            ->dontSee($otherShelf->name);
+    }
+
     public function test_books_create_all_permissions()
     {
         $this->checkAccessPermission('book-create-all', [
diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php
index 325979e74..581dac5f1 100644
--- a/tests/SharedTestHelpers.php
+++ b/tests/SharedTestHelpers.php
@@ -1,9 +1,11 @@
 <?php namespace Tests;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Chapter;
 use BookStack\Entity;
 use BookStack\Repos\EntityRepo;
+use BookStack\Repos\PermissionsRepo;
 use BookStack\Role;
 use BookStack\Services\PermissionService;
 use BookStack\Services\SettingService;
@@ -69,6 +71,25 @@ trait SharedTestHelpers
         return $user;
     }
 
+    /**
+     * Regenerate the permission for an entity.
+     * @param Entity $entity
+     */
+    protected function regenEntityPermissions(Entity $entity)
+    {
+        $this->app[PermissionService::class]->buildJointPermissionsForEntity($entity);
+        $entity->load('jointPermissions');
+    }
+
+    /**
+     * Create and return a new bookshelf.
+     * @param array $input
+     * @return Bookshelf
+     */
+    public function newShelf($input = ['name' => 'test shelf', 'description' => 'My new test shelf']) {
+        return $this->app[EntityRepo::class]->createFromInput('bookshelf', $input, false);
+    }
+
     /**
      * Create and return a new book.
      * @param array $input
@@ -140,4 +161,30 @@ trait SharedTestHelpers
         $entity->load('jointPermissions');
     }
 
+    /**
+     * Give the given user some permissions.
+     * @param \BookStack\User $user
+     * @param array $permissions
+     */
+    protected function giveUserPermissions(\BookStack\User $user, $permissions = [])
+    {
+        $newRole = $this->createNewRole($permissions);
+        $user->attachRole($newRole);
+        $user->load('roles');
+        $user->permissions(false);
+    }
+
+    /**
+     * Create a new basic role for testing purposes.
+     * @param array $permissions
+     * @return Role
+     */
+    protected function createNewRole($permissions = [])
+    {
+        $permissionRepo = app(PermissionsRepo::class);
+        $roleData = factory(Role::class)->make()->toArray();
+        $roleData['permissions'] = array_flip($permissions);
+        return $permissionRepo->saveNewRole($roleData);
+    }
+
 }
\ No newline at end of file
diff --git a/tests/TestCase.php b/tests/TestCase.php
index e0f160eed..939a1a91e 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -2,13 +2,13 @@
 
 use Illuminate\Foundation\Testing\DatabaseTransactions;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
-use Illuminate\Foundation\Testing\TestResponse;
 
 abstract class TestCase extends BaseTestCase
 {
     use CreatesApplication;
     use DatabaseTransactions;
     use SharedTestHelpers;
+
     /**
      * The base URL to use while testing the application.
      * @var string
@@ -18,11 +18,46 @@ abstract class TestCase extends BaseTestCase
     /**
      * Assert a permission error has occurred.
      * @param TestResponse $response
+     * @return TestCase
      */
     protected function assertPermissionError(TestResponse $response)
     {
         $response->assertRedirect('/');
-        $this->assertTrue(session()->has('error'));
+        $this->assertSessionHas('error');
         session()->remove('error');
+        return $this;
+    }
+
+    /**
+     * Assert the session contains a specific entry.
+     * @param string $key
+     * @return $this
+     */
+    protected function assertSessionHas(string $key)
+    {
+        $this->assertTrue(session()->has($key), "Session does not contain a [{$key}] entry");
+        return $this;
+    }
+
+    /**
+     * Override of the get method so we can get visibility of custom TestResponse methods.
+     * @param  string  $uri
+     * @param  array  $headers
+     * @return TestResponse
+     */
+    public function get($uri, array $headers = [])
+    {
+        return parent::get($uri, $headers);
+    }
+
+    /**
+     * Create the test response instance from the given response.
+     *
+     * @param  \Illuminate\Http\Response $response
+     * @return TestResponse
+     */
+    protected function createTestResponse($response)
+    {
+        return TestResponse::fromBaseResponse($response);
     }
 }
\ No newline at end of file
diff --git a/tests/TestResponse.php b/tests/TestResponse.php
new file mode 100644
index 000000000..a68a5783f
--- /dev/null
+++ b/tests/TestResponse.php
@@ -0,0 +1,141 @@
+<?php namespace Tests;
+
+use \Illuminate\Foundation\Testing\TestResponse as BaseTestResponse;
+use Symfony\Component\DomCrawler\Crawler;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+/**
+ * Class TestResponse
+ * Custom extension of the default Laravel TestResponse class.
+ * @package Tests
+ */
+class TestResponse extends BaseTestResponse {
+
+    protected $crawlerInstance;
+
+    /**
+     * Get the DOM Crawler for the response content.
+     * @return Crawler
+     */
+    protected function crawler()
+    {
+        if (!is_object($this->crawlerInstance)) {
+            $this->crawlerInstance = new Crawler($this->getContent());
+        }
+        return $this->crawlerInstance;
+    }
+
+    /**
+     * Assert the response contains the specified element.
+     * @param string $selector
+     * @return $this
+     */
+    public function assertElementExists(string $selector)
+    {
+        $elements = $this->crawler()->filter($selector);
+        PHPUnit::assertTrue(
+            $elements->count() > 0,
+            'Unable to find element matching the selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+        return $this;
+    }
+
+    /**
+     * Assert the response does not contain the specified element.
+     * @param string $selector
+     * @return $this
+     */
+    public function assertElementNotExists(string $selector)
+    {
+        $elements = $this->crawler()->filter($selector);
+        PHPUnit::assertTrue(
+            $elements->count() === 0,
+            'Found elements matching the selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+        return $this;
+    }
+
+    /**
+     * Assert the response includes a specific element containing the given text.
+     * @param string $selector
+     * @param string $text
+     * @return $this
+     */
+    public function assertElementContains(string $selector, string $text)
+    {
+        $elements = $this->crawler()->filter($selector);
+        $matched = false;
+        $pattern = $this->getEscapedPattern($text);
+        foreach ($elements as $element) {
+            $element = new Crawler($element);
+            if (preg_match("/$pattern/i", $element->html())) {
+                $matched = true;
+                break;
+            }
+        }
+
+        PHPUnit::assertTrue(
+            $matched,
+            'Unable to find element of selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'containing text'.PHP_EOL.PHP_EOL.
+            "[{$text}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+
+        return $this;
+    }
+
+    /**
+     * Assert the response does not include a specific element containing the given text.
+     * @param string $selector
+     * @param string $text
+     * @return $this
+     */
+    public function assertElementNotContains(string $selector, string $text)
+    {
+        $elements = $this->crawler()->filter($selector);
+        $matched = false;
+        $pattern = $this->getEscapedPattern($text);
+        foreach ($elements as $element) {
+            $element = new Crawler($element);
+            if (preg_match("/$pattern/i", $element->html())) {
+                $matched = true;
+                break;
+            }
+        }
+
+        PHPUnit::assertTrue(
+            !$matched,
+            'Found element of selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'containing text'.PHP_EOL.PHP_EOL.
+            "[{$text}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+
+        return $this;
+    }
+
+    /**
+     * Get the escaped text pattern for the constraint.
+     * @param  string  $text
+     * @return string
+     */
+    protected function getEscapedPattern($text)
+    {
+        $rawPattern = preg_quote($text, '/');
+        $escapedPattern = preg_quote(e($text), '/');
+        return $rawPattern == $escapedPattern
+            ? $rawPattern : "({$rawPattern}|{$escapedPattern})";
+    }
+
+}