ozankurt/laravel-modules-resource-library

SaaS resource library: nested folders with per-folder permissions, versioned items (video link, file, document, URL).

Maintainers

Package info

github.com/OzanKurt/KurtModules-ResourceLibrary

pkg:composer/ozankurt/laravel-modules-resource-library

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-07-21 06:43 UTC

This package is auto-updated.

Last update: 2026-07-21 06:48:45 UTC


README

SaaS resource library module for Laravel: nested folders with per-folder permissions, versioned items, mixed kinds (video link, file, document, external URL), translatable content, Spatie medialibrary, access logging.

v3.0 rename

This is the v3.0 rename of the previous ozankurt/laravel-modules-library v2 package. Composer name, PHP namespace, service provider, config key, console signatures, and table prefix all change from library* to resource_library*. No functional behavior changes. See UPGRADE-3.0.md for the consumer migration steps.

The rename disambiguates from the new ozankurt/laravel-modules-media-library package — a WordPress-style media bucket — which keeps the cleaner name.

Requirements

  • PHP 8.4+
  • Laravel 12.x or 13.x
  • ozankurt/laravel-modules-core v2.x

Installation

composer require ozankurt/laravel-modules-resource-library

Publish config and migrations:

php artisan vendor:publish --tag=resource-library-config
php artisan vendor:publish --tag=resource-library-migrations
php artisan migrate

Concepts

  • Folder — node in a tree (self-referential parent_id). Denormalised path column for fast ancestry queries (bounded to 1024 chars; see Max depth). visibility controls fallback behaviour: public / restricted / private.
  • Item — leaf in a folder. kind is one of video_link, file, document, external_url. Each kind stores its payload differently.
  • Version — every mutation creates a new ItemVersion row. current_version_id points at the active one.
  • Permission — per-folder ACL. Subject is user, role, or everyone. Grants view, download, or manage. Rows cascade to descendants by default. Resolution is additive (see ACL resolution). Note: role subjects only resolve once a role source is configured (a resource-library.roles.resolver callable) or the app ships a custom resolver (see Subject resolver).
  • Access log — audit row written on download (and optionally view) of an item.

What it provides

  • Models: Folder, Item, ItemVersion, Tag, FolderPermission, AccessLog.
  • Enums: FolderVisibility, ItemKind, PermissionSubjectType, Capability, AccessAction.
  • Access service: Kurt\Modules\ResourceLibrary\Access\ResourceLibraryAccess::check($user, Folder|Item, Capability).
  • Folder::moveTo(?Folder $newParent) — rewrites a whole subtree's path + depth with a single anchored UPDATE (plus the moved folder's own row), independent of subtree size. Guards against cycles and destination slug collisions with a CannotMoveFolderException.
  • Policies (FolderPolicy, ItemPolicy) that delegate to ResourceLibraryAccess. Global canAdminResourceLibrary gate bypasses everything.
  • Console commands: resource-library:recount, resource-library:prune-versions, resource-library:rebuild-paths, resource-library:demo.
  • Domain events: FolderCreated/Updated/Deleted/Moved, ItemCreated/Updated/Published/Unpublished/Deleted, ItemVersionCreated, ItemAccessed, TagCreated/Deleted, FolderPermissionChanged.

Subject resolver

Permission resolution maps the host application's identity model (user + roles) onto module subjects. The default resolver ships:

[Subject(Everyone, null), Subject(User, (string) $user->getKey())]

Wiring roles (the easy path)

To make role grants work without writing a resolver class, point the default resolver at your app's role source with a callable at resource-library.roles.resolver. It receives the current authenticatable and returns that subject's role ids (an array, or an Arrayable/Collection of int|string):

// config/resource-library.php
'roles' => [
    'resolver' => fn ($user) => $user->roles->pluck('id'),
],

With this set, the default resolver additionally emits Subject(Role, (string) $roleId) for each id, so a role permission row that matches one of the user's roles resolves out of the box. The Filament ACL relation managers detect the configured source and re-enable the role subject-type option automatically.

Ids are cast to strings and matched against FolderPermission.subject_value, so store role grants using the same id the resolver returns.

A closure cannot survive php artisan config:cache. If you cache config, use a custom resolver class (below) instead of a closure here.

Custom resolver (full control)

Apps that need more than a role-id list write a small ResourceLibrarySubjectResolver and bind its FQCN via config('resource-library.subject_resolver'):

final class AppSubjectResolver implements ResourceLibrarySubjectResolver
{
    public function subjects(?Authenticatable $user): array
    {
        $subjects = [new Subject(PermissionSubjectType::Everyone, null)];

        if ($user !== null) {
            $subjects[] = new Subject(PermissionSubjectType::User, (string) $user->getKey());

            foreach ($user->roles as $role) {
                $subjects[] = new Subject(PermissionSubjectType::Role, (string) $role->id);
            }
        }

        return $subjects;
    }
}

When neither is wired, role grants are inert. The default resolver with no configured role source emits only everyone and user subjects, so a role permission row never matches. The Filament ACL relation managers reflect this honestly: they hide the role subject-type option (and flag it in a helper text) until a role source or custom resolver is configured, so admins are never offered a grant that silently does nothing. This keeps the package fully backward compatible: no config means no change in behaviour.

ACL resolution

ResourceLibraryAccess::check($user, Folder|Item, Capability) resolves the effective capability with an additive, most-permissive-wins model:

  • The resolver walks the target folder and its entire ancestor chain and takes the maximum capability rank it can find (view < download < manage). It does not stop at the nearest matching ancestor.
  • On the target folder itself, any matching row counts. On ancestors, only rows with cascade = true count.
  • The folder's visibility fallback contributes its own baseline to the same maximum: publicdownload, privatemanage for the owner, restricted ⇒ nothing. A restricted folder still inherits cascading ancestor grants; "restricted" only caps this fallback.

Because it takes the maximum across the whole chain, a broad, closer grant can never downgrade a narrower, higher grant further up. For example, a user granted manage on a grandparent keeps manage on a leaf even if the parent carries a cascading Everyone: view.

Behaviour change (v3.1 → next): earlier versions used "nearest-ancestor wins", where the closest matching ancestor's grant shadowed farther ones even when lower. That footgun is removed. If your app relied on a closer, lower grant to cap access inherited from higher up, that capping no longer happens — review your ACL rows before upgrading.

API

The package ships an out-of-the-box JSON REST API built on the Core "API kit". It is safe-by-default: nothing is registered until you opt in.

The API enforces the per-folder ACL on every request. Reads are permission-scoped — listings only ever return folders/items the current subject may view, and show endpoints 403 when the subject lacks the view capability. Writes check the corresponding capability (manage) before acting. A guest only ever sees public / everyone-granted content; there is no public-anonymous resource library. This is the whole point of the module, so it is not optional or configurable away.

Enabling it

Set the HTTP mode to api (the default is headless):

RESOURCE_LIBRARY_HTTP_MODE=api

The http config block (published to config/resource-library.php) drives the route group:

'http' => [
    'mode' => env('RESOURCE_LIBRARY_HTTP_MODE', 'headless'), // headless | api | ui
    'prefix' => 'api/resource-library',
    'middleware' => ['api'],
    'auth_middleware' => ['auth'], // appended to write routes + the grant surface
    'rate_limit' => '60,1',        // maxAttempts,decayMinutes (throttle "resource-library-api")
],

In headless mode no routes are registered at all. Read routes stay in the base (unauthenticated-eligible) group so the everyone grant keeps working, but they are not unauthorised — each still authorises the subject against the folder ACL. Write routes and the ACL-grant surface additionally require the module auth_middleware.

Endpoints

All paths are under the configured prefix (api/resource-library by default). Named routes use the resource-library.api. prefix.

Method Path Action ACL
GET folders List root folders (or children via ?parent=<id>) scoped to view
GET folders/{folder} Show a folder view
POST folders Create a folder (child needs parent_id) manage on parent (root: any auth user)
PATCH folders/{folder} Update a folder manage
DELETE folders/{folder} Delete a folder manage
POST folders/{folder}/move Re-parent a folder (parent_id, null = root) manage on source and target
GET folders/{folder}/permissions List ACL grants (share) manage
POST folders/{folder}/permissions Add an ACL grant (share) manage
DELETE folders/{folder}/permissions/{permission} Revoke an ACL grant manage
GET folders/{folder}/items List items in a folder (drafts hidden from non-managers) view folder
GET items/{item} Show an item (resolves file/video-link/document/external-url) view
POST items Create an item (folder_id) manage on folder
PATCH items/{item} Update an item (published toggles publish) manage
DELETE items/{item} Delete an item manage
GET items/{item}/versions List an item's versions (newest first) view
POST items/{item}/versions Add a new version manage

Index endpoints accept the Core query params: ?sort=field,-other, ?filter[field]=value, ?per_page= and ?page=. Responses use the Core envelope ({ "data": ..., "meta": ... }); listings carry meta.pagination.

Filament admin

The package ships parallel admin resource sets for Filament v3, v4, and v5FolderResource, ItemResource, TagResource, and AccessLogResource. The correct set is chosen at runtime from the installed Filament major, so you register a single version-dispatching plugin on your panel:

use Filament\Panel;
use Kurt\Modules\ResourceLibrary\Filament\ResourceLibraryPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(ResourceLibraryPlugin::make());
}

ResourceLibraryPlugin::make() resolves to the matching V3/V4/V5 plugin via Kurt\Modules\Core\Support\FilamentVersion. Install whichever Filament major your app uses — items with file/document kinds use the Spatie media library upload field:

# whichever your app runs
composer require filament/filament:"^3.0|^4.0|^5.0"
composer require filament/spatie-laravel-media-library-plugin:"^3.0|^4.0|^5.0"

What the resources give you:

  • Folders — per-locale (en/tr) translatable name/description, a parent (tree) select and a visibility enum select; a table with name, path, a visibility badge and item count plus a visibility filter; and an access-control relation manager for the per-folder ACL (subject type/value, capability, cascade).
  • Items — translatable title/description, a kind enum select that reactively shows an external_url field for video-link/external-URL kinds and a Spatie media-library upload for file/document kinds; folder and tag relationship selects; a published_at picker; a table with kind and published filters; and a read-only versions relation manager showing the version history.
  • Tags — translatable name with a colour picker and a colour swatch column.
  • Access logread-only (no create/edit): a table of item, user, action badge and timestamp, filterable by action and date range, with a view action for the full audit row.

Max depth

Ancestry is resolved from the denormalised path column (/slug/slug/…), which is stored as VARCHAR(1024). On MySQL/MariaDB the column is indexed with a bounded 191-char prefix index (utf8mb4-safe) that is sufficient for the anchored path LIKE '/a/b/%' scans; PostgreSQL indexes the full column; SQLite does not enforce the length. In practice 1024 characters comfortably supports ~24 levels of nesting at ~40-char slugs — deeper trees should use shorter slugs. The bound is enforced by the 2026_07_20_000100_widen_resource_library_folder_path migration.

License

MIT (c) Ozan Kurt