diff --git a/app/Actions/ActivityService.php b/app/Actions/ActivityService.php
index 33ed44b32..983c1a603 100644
--- a/app/Actions/ActivityService.php
+++ b/app/Actions/ActivityService.php
@@ -105,10 +105,10 @@ class ActivityService
         $queryIds = [$entity->getMorphClass() => [$entity->id]];
 
         if ($entity instanceof Book) {
-            $queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
+            $queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->scopes('visible')->pluck('id');
         }
         if ($entity instanceof Book || $entity instanceof Chapter) {
-            $queryIds[(new Page())->getMorphClass()] = $entity->pages()->visible()->pluck('id');
+            $queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
         }
 
         $query = $this->activity->newQuery();
diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php
index e3a38537a..e529b80fd 100644
--- a/app/Auth/Access/LdapService.php
+++ b/app/Auth/Access/LdapService.php
@@ -165,7 +165,7 @@ class LdapService
      * Bind the system user to the LDAP connection using the given credentials
      * otherwise anonymous access is attempted.
      *
-     * @param $connection
+     * @param resource $connection
      *
      * @throws LdapException
      */
diff --git a/app/Auth/Access/Oidc/OidcJwtSigningKey.php b/app/Auth/Access/Oidc/OidcJwtSigningKey.php
index 9a5b3833a..a70f3b3c7 100644
--- a/app/Auth/Access/Oidc/OidcJwtSigningKey.php
+++ b/app/Auth/Access/Oidc/OidcJwtSigningKey.php
@@ -41,16 +41,18 @@ class OidcJwtSigningKey
     protected function loadFromPath(string $path)
     {
         try {
-            $this->key = PublicKeyLoader::load(
+            $key = PublicKeyLoader::load(
                 file_get_contents($path)
-            )->withPadding(RSA::SIGNATURE_PKCS1);
+            );
         } catch (\Exception $exception) {
             throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
         }
 
-        if (!($this->key instanceof RSA)) {
+        if (!$key instanceof RSA) {
             throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
         }
+
+        $this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
     }
 
     /**
@@ -81,14 +83,19 @@ class OidcJwtSigningKey
         $n = strtr($jwk['n'] ?? '', '-_', '+/');
 
         try {
-            /** @var RSA $key */
-            $this->key = PublicKeyLoader::load([
+            $key = PublicKeyLoader::load([
                 'e' => new BigInteger(base64_decode($jwk['e']), 256),
                 'n' => new BigInteger(base64_decode($n), 256),
-            ])->withPadding(RSA::SIGNATURE_PKCS1);
+            ]);
         } catch (\Exception $exception) {
             throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
         }
+
+        if (!$key instanceof RSA) {
+            throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
+        }
+
+        $this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
     }
 
     /**
diff --git a/app/Auth/Access/SocialAuthService.php b/app/Auth/Access/SocialAuthService.php
index 23e95970c..0df5ceb5e 100644
--- a/app/Auth/Access/SocialAuthService.php
+++ b/app/Auth/Access/SocialAuthService.php
@@ -12,6 +12,7 @@ use Illuminate\Support\Str;
 use Laravel\Socialite\Contracts\Factory as Socialite;
 use Laravel\Socialite\Contracts\Provider;
 use Laravel\Socialite\Contracts\User as SocialUser;
+use Laravel\Socialite\Two\GoogleProvider;
 use SocialiteProviders\Manager\SocialiteWasCalled;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
@@ -278,7 +279,7 @@ class SocialAuthService
     {
         $driver = $this->socialite->driver($driverName);
 
-        if ($driverName === 'google' && config('services.google.select_account')) {
+        if ($driver instanceof GoogleProvider && config('services.google.select_account')) {
             $driver->with(['prompt' => 'select_account']);
         }
 
diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php
index 735d25a99..8217d2cab 100644
--- a/app/Entities/Models/Book.php
+++ b/app/Entities/Models/Book.php
@@ -79,53 +79,43 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Get all pages within this book.
-     *
-     * @return HasMany
      */
-    public function pages()
+    public function pages(): HasMany
     {
         return $this->hasMany(Page::class);
     }
 
     /**
      * Get the direct child pages of this book.
-     *
-     * @return HasMany
      */
-    public function directPages()
+    public function directPages(): HasMany
     {
         return $this->pages()->where('chapter_id', '=', '0');
     }
 
     /**
      * Get all chapters within this book.
-     *
-     * @return HasMany
      */
-    public function chapters()
+    public function chapters(): HasMany
     {
         return $this->hasMany(Chapter::class);
     }
 
     /**
      * Get the shelves this book is contained within.
-     *
-     * @return BelongsToMany
      */
-    public function shelves()
+    public function shelves(): BelongsToMany
     {
         return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
     }
 
     /**
      * Get the direct child items within this book.
-     *
-     * @return Collection
      */
     public function getDirectChildren(): Collection
     {
-        $pages = $this->directPages()->visible()->get();
-        $chapters = $this->chapters()->visible()->get();
+        $pages = $this->directPages()->scopes('visible')->get();
+        $chapters = $this->chapters()->scopes('visible')->get();
 
         return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
     }
diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php
index e4d9775b7..b9ebab92e 100644
--- a/app/Entities/Models/Bookshelf.php
+++ b/app/Entities/Models/Bookshelf.php
@@ -37,7 +37,7 @@ class Bookshelf extends Entity implements HasCoverImage
      */
     public function visibleBooks(): BelongsToMany
     {
-        return $this->books()->visible();
+        return $this->books()->scopes('visible');
     }
 
     /**
diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php
index 224ded935..8fc2d333d 100644
--- a/app/Entities/Models/Chapter.php
+++ b/app/Entities/Models/Chapter.php
@@ -23,6 +23,7 @@ class Chapter extends BookChild
 
     /**
      * Get the pages that this chapter contains.
+     * @return HasMany<Page>
      */
     public function pages(string $dir = 'ASC'): HasMany
     {
@@ -50,7 +51,8 @@ class Chapter extends BookChild
      */
     public function getVisiblePages(): Collection
     {
-        return $this->pages()->visible()
+        return $this->pages()
+        ->scopes('visible')
         ->orderBy('draft', 'desc')
         ->orderBy('priority', 'asc')
         ->get();
diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php
index 2b6f85d24..0eb402284 100644
--- a/app/Entities/Models/Entity.php
+++ b/app/Entities/Models/Entity.php
@@ -121,11 +121,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
             return true;
         }
 
-        if (($entity->isA('chapter') || $entity->isA('page')) && $this->isA('book')) {
+        if (($entity instanceof BookChild) && $this instanceof Book) {
             return $entity->book_id === $this->id;
         }
 
-        if ($entity->isA('page') && $this->isA('chapter')) {
+        if ($entity instanceof Page && $this instanceof Chapter) {
             return $entity->chapter_id === $this->id;
         }
 
diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php
index a3c4379a6..2bfa169f4 100644
--- a/app/Entities/Models/PageRevision.php
+++ b/app/Entities/Models/PageRevision.php
@@ -63,10 +63,8 @@ class PageRevision extends Model
 
     /**
      * Get the previous revision for the same page if existing.
-     *
-     * @return \BookStack\Entities\PageRevision|null
      */
-    public function getPrevious()
+    public function getPrevious(): ?PageRevision
     {
         $id = static::newQuery()->where('page_id', '=', $this->page_id)
             ->where('id', '<', $this->id)
diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php
index ed5534f08..24fc1e7dd 100644
--- a/app/Entities/Repos/PageRepo.php
+++ b/app/Entities/Repos/PageRepo.php
@@ -69,9 +69,10 @@ class PageRepo
      */
     public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
     {
+        /** @var ?PageRevision $revision */
         $revision = PageRevision::query()
             ->whereHas('page', function (Builder $query) {
-                $query->visible();
+                $query->scopes('visible');
             })
             ->where('slug', '=', $pageSlug)
             ->where('type', '=', 'version')
@@ -80,7 +81,7 @@ class PageRepo
             ->with('page')
             ->first();
 
-        return $revision ? $revision->page : null;
+        return $revision->page ?? null;
     }
 
     /**
diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php
index c60cf0311..b95131fce 100644
--- a/app/Entities/Tools/PageContent.php
+++ b/app/Entities/Tools/PageContent.php
@@ -12,6 +12,8 @@ use BookStack\Uploads\ImageRepo;
 use BookStack\Uploads\ImageService;
 use BookStack\Util\HtmlContentFilter;
 use DOMDocument;
+use DOMElement;
+use DOMNode;
 use DOMNodeList;
 use DOMXPath;
 use Illuminate\Support\Str;
@@ -237,9 +239,9 @@ class PageContent
      * A map for existing ID's should be passed in to check for current existence.
      * Returns a pair of strings in the format [old_id, new_id].
      */
-    protected function setUniqueId(\DOMNode $element, array &$idMap): array
+    protected function setUniqueId(DOMNode $element, array &$idMap): array
     {
-        if (!$element instanceof \DOMElement) {
+        if (!$element instanceof DOMElement) {
             return ['', ''];
         }
 
@@ -321,7 +323,7 @@ class PageContent
      */
     protected function headerNodesToLevelList(DOMNodeList $nodeList): array
     {
-        $tree = collect($nodeList)->map(function ($header) {
+        $tree = collect($nodeList)->map(function (DOMElement $header) {
             $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
             $text = mb_substr($text, 0, 100);
 
diff --git a/app/Entities/Tools/SearchIndex.php b/app/Entities/Tools/SearchIndex.php
index 79139ec24..d43d98207 100644
--- a/app/Entities/Tools/SearchIndex.php
+++ b/app/Entities/Tools/SearchIndex.php
@@ -9,6 +9,7 @@ use BookStack\Entities\Models\Page;
 use BookStack\Entities\Models\SearchTerm;
 use DOMDocument;
 use DOMNode;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Collection;
 
 class SearchIndex
@@ -76,7 +77,9 @@ class SearchIndex
         foreach ($this->entityProvider->all() as $entityModel) {
             $indexContentField = $entityModel instanceof Page ? 'html' : 'description';
             $selectFields = ['id', 'name', $indexContentField];
-            $total = $entityModel->newQuery()->withTrashed()->count();
+            /** @var Builder<Entity> $query */
+            $query = $entityModel->newQuery();
+            $total = $query->withTrashed()->count();
             $chunkSize = 250;
             $processed = 0;
 
@@ -223,7 +226,7 @@ class SearchIndex
         if ($entity instanceof Page) {
             $bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
         } else {
-            $bodyTermsMap = $this->generateTermScoreMapFromText($entity->description ?? '', $entity->searchFactor);
+            $bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
         }
 
         $mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
diff --git a/app/Entities/Tools/SearchRunner.php b/app/Entities/Tools/SearchRunner.php
index a4f131482..a0a44f3a5 100644
--- a/app/Entities/Tools/SearchRunner.php
+++ b/app/Entities/Tools/SearchRunner.php
@@ -145,13 +145,13 @@ class SearchRunner
 
         if ($entityModelInstance instanceof BookChild) {
             $relations['book'] = function (BelongsTo $query) {
-                $query->visible();
+                $query->scopes('visible');
             };
         }
 
         if ($entityModelInstance instanceof Page) {
             $relations['chapter'] = function (BelongsTo $query) {
-                $query->visible();
+                $query->scopes('visible');
             };
         }
 
diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php
index fcf933726..ab62165af 100644
--- a/app/Entities/Tools/TrashCan.php
+++ b/app/Entities/Tools/TrashCan.php
@@ -15,6 +15,7 @@ use BookStack\Facades\Activity;
 use BookStack\Uploads\AttachmentService;
 use BookStack\Uploads\ImageService;
 use Exception;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Carbon;
 
 class TrashCan
@@ -141,11 +142,9 @@ class TrashCan
     {
         $count = 0;
         $pages = $chapter->pages()->withTrashed()->get();
-        if (count($pages)) {
-            foreach ($pages as $page) {
-                $this->destroyPage($page);
-                $count++;
-            }
+        foreach ($pages as $page) {
+            $this->destroyPage($page);
+            $count++;
         }
 
         $this->destroyCommonRelations($chapter);
@@ -183,9 +182,10 @@ class TrashCan
     {
         $counts = [];
 
-        /** @var Entity $instance */
         foreach ((new EntityProvider())->all() as $key => $instance) {
-            $counts[$key] = $instance->newQuery()->onlyTrashed()->count();
+            /** @var Builder<Entity> $query */
+            $query = $instance->newQuery();
+            $counts[$key] = $query->onlyTrashed()->count();
         }
 
         return $counts;
@@ -344,9 +344,9 @@ class TrashCan
         $entity->deletions()->delete();
         $entity->favourites()->delete();
 
-        if ($entity instanceof HasCoverImage && $entity->cover) {
+        if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
             $imageService = app()->make(ImageService::class);
-            $imageService->destroy($entity->cover);
+            $imageService->destroy($entity->cover()->first());
         }
     }
 }
diff --git a/app/Http/Controllers/Api/BookshelfApiController.php b/app/Http/Controllers/Api/BookshelfApiController.php
index de7284e61..bd4f23a10 100644
--- a/app/Http/Controllers/Api/BookshelfApiController.php
+++ b/app/Http/Controllers/Api/BookshelfApiController.php
@@ -75,7 +75,7 @@ class BookshelfApiController extends ApiController
         $shelf = Bookshelf::visible()->with([
             'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
             'books' => function (BelongsToMany $query) {
-                $query->visible()->get(['id', 'name', 'slug']);
+                $query->scopes('visible')->get(['id', 'name', 'slug']);
             },
         ])->findOrFail($id);
 
diff --git a/app/Http/Controllers/Api/ChapterApiController.php b/app/Http/Controllers/Api/ChapterApiController.php
index 6b226b5f0..8459b8449 100644
--- a/app/Http/Controllers/Api/ChapterApiController.php
+++ b/app/Http/Controllers/Api/ChapterApiController.php
@@ -70,7 +70,7 @@ class ChapterApiController extends ApiController
     public function read(string $id)
     {
         $chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
-            $query->visible()->get(['id', 'name', 'slug']);
+            $query->scopes('visible')->get(['id', 'name', 'slug']);
         }])->findOrFail($id);
 
         return response()->json($chapter);
diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php
index af44a6689..51cba642c 100644
--- a/app/Http/Controllers/BookController.php
+++ b/app/Http/Controllers/BookController.php
@@ -114,7 +114,7 @@ class BookController extends Controller
     {
         $book = $this->bookRepo->getBySlug($slug);
         $bookChildren = (new BookContents($book))->getTree(true);
-        $bookParentShelves = $book->shelves()->visible()->get();
+        $bookParentShelves = $book->shelves()->scopes('visible')->get();
 
         View::incrementFor($book);
         if ($request->has('shelf')) {
diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php
index 7bc12c5d1..57336ec5f 100644
--- a/app/Theming/ThemeService.php
+++ b/app/Theming/ThemeService.php
@@ -52,7 +52,7 @@ class ThemeService
      */
     public function registerCommand(Command $command)
     {
-        Artisan::starting(function(Application $application) use ($command) {
+        Artisan::starting(function (Application $application) use ($command) {
             $application->addCommands([$command]);
         });
     }
diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php
index 494ff3ac0..bfe4b5977 100644
--- a/app/Uploads/ImageRepo.php
+++ b/app/Uploads/ImageRepo.php
@@ -103,7 +103,10 @@ class ImageRepo
                 if ($filterType === 'page') {
                     $query->where('uploaded_to', '=', $contextPage->id);
                 } elseif ($filterType === 'book') {
-                    $validPageIds = $contextPage->book->pages()->visible()->pluck('id')->toArray();
+                    $validPageIds = $contextPage->book->pages()
+                        ->scopes('visible')
+                        ->pluck('id')
+                        ->toArray();
                     $query->whereIn('uploaded_to', $validPageIds);
                 }
             };
diff --git a/tests/Unit/FrameworkAssumptionTest.php b/tests/Unit/FrameworkAssumptionTest.php
new file mode 100644
index 000000000..8ad45612c
--- /dev/null
+++ b/tests/Unit/FrameworkAssumptionTest.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Tests\Unit;
+
+use BadMethodCallException;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+/**
+ * This class tests assumptions we're relying upon in the framework.
+ * This is primarily to keep track of certain bits of functionality that
+ * may be used in important areas such as to enforce permissions.
+ */
+class FrameworkAssumptionTest extends TestCase
+{
+
+    public function test_scopes_error_if_not_existing()
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('Call to undefined method BookStack\Entities\Models\Page::scopeNotfoundscope()');
+        Page::query()->scopes('notfoundscope');
+    }
+
+    public function test_scopes_applies_upon_existing()
+    {
+        // Page has SoftDeletes trait by default, so we apply our custom scope and ensure
+        // it stacks on the global scope to filter out deleted items.
+        $query = Page::query()->scopes('visible')->toSql();
+        $this->assertStringContainsString('joint_permissions', $query);
+        $this->assertStringContainsString('`deleted_at` is null', $query);
+    }
+}
diff --git a/version b/version
index 4a22367ef..8f6af9bee 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-v21.10-dev
+v21.11-dev