clean-bandits/single-action-resource-controllers-for-laravel
Organize Laravel resourceful routes into single-action (invokable) controllers. This package gives each resource action—index, show, store, update, destroy—its own focused controller class, keeping your app clean, explicit, and easy to maintain.
The package’s premise is simple: instead of one fat PostController with seven resource methods, you get one invokable controller per action (IndexPostController, StorePostController, ShowPostController, etc.), plus a route helper to wire them up with resource-style URLs and names.
Minimal steps:
composer require clean-bandits/single-action-resource-controllers-for-laravel
php artisan vendor:publish --tag=single-action-resource-controllers # if config/stubs are offered
make:resource-controller vs make:single-action-resource) before scripting it into your workflow.vendor/clean-bandits/.../src/ to see what is registered — route macros, stub paths, and any base controller/trait.php artisan make:single-action-resource Post --model=Post
You should end up with seven invokable classes and a registration snippet.routes/web.php (or api.php) using the package macro, then verify:
php artisan route:list --name=posts
You want the same seven URIs and route names you’d get from Route::resource('posts', PostController::class) — that’s the compatibility contract.Start with a read-only resource (index + show) before converting a write-heavy one. It lets you validate routing, model binding, and view responses without touching forms or validation.
One class, one verb, one job. Each generated controller should contain only the logic for its action. Anything shared (query scoping, response formatting) belongs in a base controller, a trait, or a dedicated action/service class. A typical thin controller:
final class StorePostController extends Controller
{
public function __invoke(StorePostRequest $request, CreatePost $createPost): RedirectResponse
{
$post = $createPost->handle($request->validated());
return redirect()->route('posts.show', $post);
}
}
Pair each controller with its own FormRequest. This is where the pattern pays off: StorePostRequest and UpdatePostRequest are already separate, so per-action validation rules stop leaking into each other. Same for policies — authorize() calls live in exactly one place per action.
Keep the route contract identical. The migration path from a classic resource controller should be invisible to clients. The package’s route helper should preserve URIs, verbs, and names. If it doesn’t, register the routes manually to guarantee parity:
Route::get('posts', IndexPostController::class)->name('posts.index');
Route::post('posts', StorePostController::class)->name('posts.store');
Route::get('posts/{post}', ShowPostController::class)->name('posts.show');
// ...
Declare route model binding per controller. Unlike a resource controller, there is no shared show(Post $post) signature handed to you — implicit binding comes from the route definition. If the macro accepts a model parameter, use it; otherwise type-hint consistently and let Laravel resolve {post}. Do this the same way across all seven classes or you’ll get inconsistent 404s.
Middleware per action. This is a genuine advantage: apply auth only to Store*/Update*/Destroy*, or throttle Index* differently from Show*, without only()/except() arrays drifting out of sync.
Testing. Each controller is independently instantiable, so feature tests map one-to-one to classes. Build a small helper that boots a controller with a fake request, or just hammer the routes — either way, coverage gaps are obvious because every action is a named file.
Beneath the controllers, use invokable actions or services. The controller layer becomes a transport adapter; business logic lives in classes like CreatePost, ListPosts, DeletePost. This makes the single-action controllers trivially replaceable later.
declare(strict_types=1), and namespace conventions once.posts.index vs post.index), every route() call and redirect silently breaks. Run route:list before and after on one resource and diff.__construct won’t exist unless all seven extend a common base. Create an abstract ResourceController and have the stubs extend it.php artisan route:clear / route:cache (and config:clear if you published config) are run in cached environments.Store/Update controller; 405 means the macro assigned the wrong verb. Check route:list -v first.composer.json, and treat the value as the convention, not the code — if the macro disappears, you can replace it with ten lines of Route::get/Route::post and keep the controllers. The controllers themselves are plain invokable classes, so there’s essentially no lock-in.Route::resourceActions() helper) so the rest of the team never touches the raw package API.How can I help you explore Laravel packages today?