ozankurt/modules-core
laravel-modules-core
Shared bootstrap kit for KurtModules Laravel packages.
Requirements
- PHP 8.3+
- Laravel 12.x
- (Optional) Filament 3, 4, or 5
Installation
composer require ozankurt/laravel-modules-core
What it provides
Kurt\Modules\Core\Providers\PackageServiceProvider— abstract base every kurtmodules service provider extends. Wrapsspatie/laravel-package-toolsand dispatches toregisterFilamentV{3,4,5}based on the installed Filament major.Kurt\Modules\Core\Contracts\UserResolver(+ConfigUserResolver) — resolves the consumer's user model viakurtmodules.user_modelconfig orauth.providers.users.modelfallback.Kurt\Modules\Core\Concerns\ResolvesUser— trait that gives module models auserBelongsTo()helper.Kurt\Modules\Core\Concerns\InteractsWithModuleConfig— sugar forconfig("{module}.key")access.Kurt\Modules\Core\Concerns\AbortsInProduction— trait for Artisan commands;abortIfProduction()blocks demo/destructive commands in production unless--forceis passed. Lets*:democommands drop their hand-rolled guards.Kurt\Modules\Core\Concerns\HasEnumLabels— trait giving string-backed enums a Title-CasegetLabel()and a nullgetColor()default, so downstream enums can satisfy Filament'sHasLabel/HasColorwithout Core requiring Filament.Kurt\Modules\Core\Support\FilamentVersion—::major(),::isAtLeast(),::isExactly().Kurt\Modules\Core\Enums\{Approval,MediaKind,Visibility}— generic cross-module enums.Kurt\Modules\Core\Testing\PackageTestCase— Testbench-backed base test case with an in-memoryuserstable.- API kit —
Http\HttpMode,Http\ApiRouteGroup,Http\Controllers\ApiController,Http\Concerns\HandlesApiQuery,Support\ApiRateLimiter, plus thePackageServiceProvider::registerModuleApi()hook. The config-convention-driven REST foundation every module inherits (see API kit).
Configuration
Publish the config file:
php artisan vendor:publish --tag="kurtmodules-config"
return [
'user_model' => env('KURTMODULES_USER_MODEL'),
];
Command & enum helpers
Guard demo/destructive Artisan commands so they cannot run in production without --force:
use Illuminate\Console\Command;
use Kurt\Modules\Core\Concerns\AbortsInProduction;
class SeedDemoCommand extends Command
{
use AbortsInProduction;
protected $signature = 'blog:demo {--force}';
public function handle(): int
{
if ($this->abortIfProduction()) {
return self::FAILURE;
}
// ... seed demo data ...
return self::SUCCESS;
}
}
Give a string-backed enum Filament-ready labels/colors without Core depending on Filament (the enum declares the contracts, the trait supplies the bodies):
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
use Kurt\Modules\Core\Concerns\HasEnumLabels;
enum Status: string implements HasColor, HasLabel
{
use HasEnumLabels;
case Draft = 'draft';
case InReview = 'in_review'; // getLabel() => "In Review"
case Published = 'published';
// Optional: override getColor() per case; getLabel() keeps the default.
public function getColor(): string|array|null
{
return match ($this) {
self::Published => 'success',
default => null,
};
}
}
API kit
A config-convention-driven foundation for a module's out-of-the-box REST API.
It generalises the loyalty.http.mode pattern so every module exposes a JSON
API the same way, safe-by-default: nothing is registered until a consumer
opts in, and write routes require auth + a Policy.
Config convention
Every consuming module reads these per-module keys (module slug = the value
returned by the provider's module()):
| Key | Default | Purpose |
|---|---|---|
{slug}.http.mode |
headless |
headless | api | ui. API routes register only for api/ui. |
{slug}.http.prefix |
api/{slug} |
URL prefix for the route group. |
{slug}.http.middleware |
['api'] |
Base middleware for every API route. |
{slug}.http.auth_middleware |
['auth'] |
Appended to write (authenticated) routes. |
{slug}.http.rate_limit |
'60,1' |
maxAttempts,decayMinutes for the {slug}-api throttle. |
Every group is throttled by a named limiter {slug}-api (keyed by user id, or
client IP for guests). A module's config/{slug}.php therefore ships:
'http' => [
'mode' => env('BLOG_HTTP_MODE', 'headless'),
'prefix' => 'api/blog',
'middleware' => ['api'],
'auth_middleware' => ['auth:sanctum'],
'rate_limit' => '60,1',
],
Base classes
Http\HttpMode—HttpMode::forModule($slug)resolves{slug}.http.mode(unknown →Headless);->apiEnabled(),->is(...).Http\ApiRouteGroup—ApiRouteGroup::attributes($slug, $authenticated = false)returns theRoute::group()array (prefix, mergedmiddleware+ throttle [+auth_middlewarewhen$authenticated],as="{slug}.api.").Http\Controllers\ApiController— abstract base (addsAuthorizesRequests,ValidatesRequests) with a consistent envelope:respond($data, array $meta = [], int $status = 200)→{ "data": …, "meta": … }(metaomitted when empty).respondCreated($data)(201),respondNoContent()(204, empty body).respondPaginated(LengthAwarePaginator $p, ?string $resourceClass = null)→ items asdata(mapped through the resource class when given) +meta.pagination(current_page,per_page,total,last_page).fail(string $message, int $status = 422, array $errors = [])→{ "message": …, "errors": … }.
Http\Concerns\HandlesApiQuery— allow-list-driven query helpers (never interpolate raw input as SQL identifiers):applyApiSorts($q, $r, array $allowed)—?sort=field,-other(leading-= desc); non-allowed fields dropped.applyApiFilters($q, $r, array $allowed)—?filter[field]=value;$allowedis a list of exact fields (['name']) or a mode map (['name' => 'like', 'status' => 'exact']).like→%value%.apiPaginate($q, $r, int $default = 15, int $max = 100)—?per_page=clamped to[1, $max], standard?page=.
Support\ApiRateLimiter—ApiRateLimiter::register($slug)registers the{slug}-apilimiter from{slug}.http.rate_limit.
Resources vs envelope helpers
The envelope's top-level key is data, matching Laravel API Resources. Use
Laravel resources directly (Core ships no resource base) and hand the result
of a resource to the envelope helpers so the payload is wrapped exactly once:
// Single: pass the resource itself — nested resources are not re-wrapped.
return $this->respond(PostResource::make($post));
// Collection: map through the resource, then envelope.
return $this->respondPaginated($paginator, PostResource::class);
Do not return a bare JsonResource from a route and also call respond()
— that double-wraps as {"data":{"data":…}}.
How a module exposes an API
-
Config — add the
httpblock above toconfig/{slug}.php; keepmodedefaulting toheadless. -
Resources — define
App-styleJsonResourceclasses for your models. -
Controller — extend
ApiController; keep controllers thin over domain services.use HandlesApiQueryon index controllers for sort/filter/paginate. Enforce a Policy on every write ($this->authorize(...)). -
Routes —
routes/api.php, read vs write split viaApiRouteGroup::attributes():use Illuminate\Support\Facades\Route; // Read (public) endpoints — base middleware + throttle. Route::get('posts', [PostController::class, 'index'])->name('posts.index'); Route::get('posts/{post}', [PostController::class, 'show'])->name('posts.show'); // Write endpoints — add the module auth middleware. Route::group(['middleware' => config('blog.http.auth_middleware', ['auth'])], function () { Route::post('posts', [PostController::class, 'store'])->name('posts.store'); Route::patch('posts/{post}', [PostController::class, 'update'])->name('posts.update'); Route::delete('posts/{post}', [PostController::class, 'destroy'])->name('posts.destroy'); }); -
Provider — wire it in
packageBooted(); it is a no-op in headless mode and registers the rate limiter + route group otherwise:public function packageBooted(): void { parent::packageBooted(); $this->registerModuleApi(__DIR__.'/../../routes/api.php'); }
The outer group (prefix, base middleware, throttle, "{slug}.api." name prefix)
is applied by registerModuleApi(); the route file only distinguishes read vs
write.
License
MIT © Ozan Kurt