sarfraznawaz2005 / actions
Laravel package as alternative to single action controllers with support for web and api in single class.
Requires
- php: >=7.0
- illuminate/support: ~5|~6|~7|~8|~9
README
Laravel Actions
Laravel package as an alternative to single action controllers with support for web/html and api in single class. You can use single class called Action to send appropriate web or api response automatically. It also provides easy way to validate request data.
Under the hood, action classes are normal Laravel controllers but with single public __invoke
method. This means you can do anything that you do with controllers normally like calling $this->middleware('foo')
or anything else.
Table of Contents
- Why
- Requirements
- Installation
- Example Action Class
- Usage
- Send Web or API Response Automatically
- Validation
- Utility Methods and Properties
- Creating Actions
- Registering Routes
- Bonus: Creating Plain Classes
Why
- Helps follow single responsibility principle (SRP)
- Helps keep controllers and models skinny
- Small dedicated class makes the code easier to test
- Helps avoid code duplication eg different classes for web and api
- Action classes can be callable from multiple places in your app
- Small dedicated classes really pay off in complex apps
- Expressive routes registration like
Route::get('/', HomeAction::class)
- Allows decorator pattern
Requirements
- PHP >= 7
- Laravel 5, 6
Installation
Install via composer
composer require sarfraznawaz2005/actions
That's it.
Example Action Class
class PublishPostAction extends Action { /** * Define any validation rules. */ protected $rules = []; /** * Perform the action. * * @return mixed */ public function __invoke() { // code } }
In __invoke()
method, you write actual logic of the action. Actions are invokable classes that use __invoke
magic function turning them into a Callable
which allows them to be called as function.
Usage
As Controller Actions
Primary usage of action classes is mapping them to routes so they are called automatically when visiting those routes:
// routes/web.php Route::get('post', '\App\Http\Actions\PublishPostAction'); // or Route::get('post', '\\' . PublishPostAction::class);
Note that the initial
\
here is important to ensure the namespace does not become\App\Http\Controller\App\Http\Actions\PublishPostAction
As Callable Classes
$action = new PublishPostAction(); $action();
Send Web or API Response Automatically
If you need to serve both web and api responses from same/single action class, you need to define html()
and json()
method in your action class:
class TodosListAction extends Action { protected $todos; public function __invoke(Todo $todos) { $this->todos = $todos->all(); } protected function html() { return view('index')->with('todos', $this->todos); } protected function json() { return TodosResource::collection($this->todos); } }
With these two methods present, the package will automatically send appropriate response. Browsers will receive output from html()
method and other devices will receive output from json()
method.
Under the hood, we check if Accept: application/json
header is present in request and if so it sends output from your json()
method otherwise from html()
method.
You can change this api/json detection mechanism by implementing isApi()
method, it must return boolean
value:
class TodosListAction extends Action { protected $todos; public function __invoke(Todo $todos) { $this->todos = $todos->all(); } protected function html() { return view('index')->with('todos', $this->todos); } protected function json() { return TodosResource::collection($this->todos); } public function isApi() { return request()->wantsJson() && !request()->acceptsHtml(); } }
Using Action Classes for API Requests Only
Simply return true
from isApi
method and use json
method.
Using Action Classes for Web/Browser Requests Only
This is default behaviour, you can simply return your HTML/blade views from within __invoke
or html
method if you use it.
Validation
You can perform input validation for your store
and update
methods, simply use protected $rules = []
property in your action class:
class TodoStoreAction extends Action { protected $rules = [ 'title' => 'required|min:5' ]; public function __invoke(Todo $todo) { $todo->fill(request()->all()); return $todo->save(); } }
In this case, validation will be performed before __invoke
method is called and if it fails, you will be automatically redirected back to previous form page with $errors
filled with validation errors.
Tip: Because validation is performed before
__invoke
method is called, usingrequest()->all()
will always give you valid data in__invoke
method which is why it's used in above example.
Custom Validation Messages
To implement custom validation error messages for your rules, simply use protected $messages = []
property.
Ignoring/Filtering Request Data
If you want to remove some request data before it is validated/persisted, you can use the protected $ignored = ['id'];
. In this case, id
will be removed from the request eg in other words it will be as if it was not posted in the request.
Utility Methods and Properties
Consider following action which is supposed to save todo/task into database and send appropriate response to web and api:
class TodoStoreAction extends Action { protected $rules = [ 'title' => 'required|min:2' ]; public function __invoke(Todo $todo) { return $this->create($todo); } protected function html($result) { if (!$result) { return back()->withInput()->withErrors($this->errors); } session()->flash('success', self::MESSAGE_CREATE); return back(); } protected function json($result) { if (!$result) { return response()->json(['result' => false], Response::HTTP_INTERNAL_SERVER_ERROR); } return response()->json(['result' => true], Response::HTTP_CREATED); } }
There are few things to notice above that package provides out of the box:
- Inside
__invoke
method, we used$this->create
method as shorthand/quick way to create a new todo record. Similarly,$this->update
and$this->delete
methods can also be used. They all returnboolean
value. They all also accept optional callback:
return $this->create($todo, function ($result) { if ($result) { flash(self::MESSAGE_CREATE, 'success'); } else { flash(self::MESSAGE_FAIL, 'danger'); } });
Using these utility methods is not required though.
-
If you return something from
__invoke
method, it can be read later fromhtml
andjson
methods as first parameter. In this case, boolean result of todo creation (return $this->create($todo)
) was used in bothhtml
andjson
methods via$result
variable whos name can be anything. -
Any validation errors are saved in
$this->errors
variable which can be used as needed. -
In
html()
method, we have usedself::MESSAGE_CREATE
which comes from parent action class. Similar,self::MESSAGE_UPDATE
,self::MESSAGE_DELETE
andself::MESSAGE_FAIL
can also be used.
Tip: You can choose to not use any utility methods/properties/validations offered by this package which is completely fine. Remember, action classes are normal Laravel controllers you can use however you like.
Transforming Request Data
If you want to transform request data before validation is performed and before __invoke()
method is called, you can define transform
method in your action class which must return an array:
public function transform(Request $request): array { return [ 'description' => trim(strip_tags($request->description)), 'user_id' => auth()->user->id ?? 0, ]; }
The transform method can be used to both modify existing request variables as well as adding new variables to request data. In above example, we modify description
to trim any whitespace and remove any html tags. We also add user_id
to request data which wasn't in it before.
Behind the scene, we simply merge whatever is returned from this method into original Request data.
Creating Actions
- Create an action
php artisan make:action ShowPost
ShowPost
action will be created
- Create actions for all resource actions (
index
,show
,create
,store
,edit
,update
,destroy
)
php artisan make:action Post --resource
IndexPost
,ShowPost
,CreatePost
,StorePost
,EditPost
,UpdatePost
,DestroyPost
actions will be created
- Create actions for all API actions (
create
,edit
excluded)
php artisan make:action Post --api
IndexPost
,ShowPost
,StorePost
,UpdatePost
,DestroyPost
actions will be created
- Create actions by the specified actions
php artisan make:action Post --actions=show,destroy,approve
ShowPost
,DestroyPost
,ApprovePost
actions will be created
- Exclude specified actions
php artisan make:action Post --resource --except=index,show,edit
CreatePost
,StorePost
,UpdatePost
,DestroyPost
actions will be created
- Specify namespace for actions creating (relative path)
php artisan make:action Post --resource --namespace=Post
IndexPost
,ShowPost
,CreatePost
,StorePost
,EditPost
,UpdatePost
,DestroyPost
actions will be created underApp\Http\Actions\Post
namespace inapp/Http/Actions/Post
directory
- Specify namespace for actions creating (absolute path)
php artisan make:action ActivateUser --namespace=\\App\\Foo\\Bar
ActivateUser
action will be created underApp\Foo\Bar
namespace inapp/Foo/Bar
directory
- Force create
php artisan make:action EditPost --force
If
EditPost
action already exists, it will be overwritten by the new one
Registering Routes
Here are several ways to register actions in routes:
In separate actions.php
route file
- Create
routes/actions.php
file (you can choose any name, it's just an example) - Define the "action" route group in
app/Providers/RouteServiceProvider.php
With namespace auto prefixing
// app/Providers/RouteServiceProvider.php protected function mapActionRoutes() { Route::middleware('web') ->namespace('App\Http\Actions') ->group(base_path('routes/actions.php')); }
// app/Providers/RouteServiceProvider.php public function map() { $this->mapApiRoutes(); $this->mapWebRoutes(); $this->mapActionRoutes(); // }
// routes/actions.php Route::get('/post/{post}', 'ShowPost');
Without namespace auto prefixing
// app/Providers/RouteServiceProvider.php protected function mapActionRoutes() { Route::middleware('web') ->group(base_path('routes/actions.php')); }
// app/Providers/RouteServiceProvider.php public function map() { $this->mapApiRoutes(); $this->mapWebRoutes(); $this->mapActionRoutes(); // }
// routes/actions.php use App\Actions\ShowPost; Route::get('/post/{post}', ShowPost::class); // pretty sweet, isn't it? 😍
In web.php
route file
- Change the namespace for "web" group in
RouteServiceProvider.php
// app/Providers/RouteServiceProvider.php protected function mapWebRoutes() { Route::middleware('web') ->namespace('App\Http') // pay attention here ->group(base_path('routes/web.php')); }
- Put actions and controllers in different route groups in
routes/web.php
file and prepend an appropriate namespace for each of them
// routes/web.php Route::group(['namespace' => 'Actions'], function () { Route::get('/posts/{post}', 'ShowPost'); Route::delete('/posts/{post}', 'DestroyPost'); }); Route::group(['namespace' => 'Controllers'], function () { Route::get('/users', 'UserController@index'); Route::get('/users/{user}', 'UserController@show'); });
Bonus: Creating Plain Classes
The package also provides make:class
console command to create plain classes:
php artisan make:class FooBar
FooBar
class will be created under app/Actions
folder:
namespace App\Actions; class FooBar { /** * Perform the action. * * @return mixed */ public function execute() { // } }
Note that these are plain old PHP classes you can use for any purpose. Ideally, they should not be dependent on Laravel framework or any other framework and should have single public method as api such as execute
and any more private/protected methods needed for that class to work. This will allow you to use them across different projects and frameworks. You can also think of them as service classes.
Credits
License
Please see the license file for more information.