Overview
This Laravel ERP system follows a layered architecture pattern designed to maintain “Skinny Controllers” , promote code reusability, and facilitate unit testing. Every module follows the same structure and implementation checklist.
All modules follow the same pattern: Model → Table → Filters → Form Requests → Services → Controller. This consistency makes the codebase predictable and maintainable.
Architecture Layers
The system is organized into 6 distinct layers, each with a specific responsibility:
Layer Responsibilities
Model Layer Location: app/Models/[Module].phpManages data persistence and relationships. Must define scopeWithIndexRelations() for eager loading.
Table Configuration Location: app/Tables/[Module]Table.phpCentralizes column names and visibility logic for desktop/mobile views.
Filter Pipeline Location: app/Filters/[Module]/Each filter is an independent class. Keeps controllers clean of WHERE clauses.
Form Requests Location: app/Http/Requests/[Module]/Validates data and verifies Spatie permissions before controller execution.
Catalog Service Location: app/Services/[Module]/[Module]CatalogService.phpProvides data for dropdowns and filters, filtered by country_id or other global parameters.
Business Service Location: app/Services/[Module]/[Module]Service.phpExecutes write operations, complex calculations, and bulk actions. Handles DB transactions.
Implementation Checklist
Use this checklist when creating a new module to ensure consistency:
Database & Security
Create and run migration with SoftDeletes: php artisan make:migration create_[modules]_table
app/database/migrations/xxxx_create_modules_table.php
use Illuminate\Database\Migrations\ Migration ;
use Illuminate\Database\Schema\ Blueprint ;
use Illuminate\Support\Facades\ Schema ;
return new class extends Migration
{
public function up () : void
{
Schema :: create ( 'modules' , function ( Blueprint $table ) {
$table -> id ();
$table -> string ( 'name' );
$table -> boolean ( 'is_active' ) -> default ( true );
$table -> timestamps ();
$table -> softDeletes ();
});
}
public function down () : void
{
Schema :: dropIfExists ( 'modules' );
}
};
Create permission seeder following Spatie pattern: database/seeders/PermissionSeeder/ModulePermissionsSeeder.php
<? php
namespace Database\Seeders\PermissionSeeder ;
use Illuminate\Database\ Seeder ;
use Spatie\Permission\Models\ Permission ;
class ModulePermissionsSeeder extends Seeder
{
public function run () : void
{
$permissions = [
'modules index' , // View list
'modules create' , // Create new
'modules edit' , // Edit records
'modules delete' , // Delete and purge
'modules restore' , // View trash and restore
];
foreach ( $permissions as $permission ) {
Permission :: firstOrCreate ([ 'name' => $permission ]);
}
}
}
Run the seeder: php artisan db:seed --class=ModulePermissionsSeeder
Configure model with relationships and scope: <? php
namespace App\Models ;
use Illuminate\Database\Eloquent\ Model ;
use Illuminate\Database\Eloquent\ SoftDeletes ;
use Illuminate\Database\Eloquent\Relations\ BelongsTo ;
class Module extends Model
{
use SoftDeletes ;
protected $fillable = [ 'name' , 'is_active' ];
protected $casts = [
'is_active' => 'boolean' ,
];
/**
* Centralize eager loading for index and exports
*/
public function scopeWithIndexRelations ( $query )
{
return $query -> with ([
'relatedModel:id,name' ,
// Add other frequently accessed relationships
]);
}
}
Backend Logic
app/Tables/ModuleTable.php
<? php
namespace App\Tables ;
class ModuleTable
{
/**
* All available columns with readable labels
*/
public static function allColumns () : array
{
return [
'id' => 'ID' ,
'name' => 'Name' ,
'is_active' => 'Status' ,
'created_at' => 'Created' ,
'updated_at' => 'Updated' ,
];
}
/**
* Default visible columns on desktop
*/
public static function defaultDesktop () : array
{
return [ 'id' , 'name' , 'is_active' , 'created_at' ];
}
/**
* Critical columns for mobile view
*/
public static function defaultMobile () : array
{
return [ 'name' , 'is_active' ];
}
}
Create the main filter class: app/Filters/Module/ModuleFilters.php
<? php
namespace App\Filters\Module ;
use App\Filters\Base\ QueryFilter ;
class ModuleFilters extends QueryFilter
{
protected function filters () : array
{
return [
'search' => ModuleSearchFilter :: class ,
'is_active' => ModuleStatusFilter :: class ,
'from_date' => ModuleDateFilter :: class ,
];
}
}
Create individual filter classes: app/Filters/Module/ModuleSearchFilter.php
<? php
namespace App\Filters\Module ;
use Illuminate\Database\Eloquent\ Builder ;
use Illuminate\Http\ Request ;
use App\Filters\Contracts\ FilterInterface ;
class ModuleSearchFilter implements FilterInterface
{
public function __construct ( protected Request $request ) {}
public function apply ( Builder $query ) : Builder
{
$value = $this -> request -> input ( 'search' );
if ( ! $value ) return $query ;
return $query -> where ( function ( $q ) use ( $value ) {
$q -> where ( 'name' , 'like' , "%{ $value }%" )
-> orWhere ( 'code' , 'like' , "%{ $value }%" );
});
}
}
Services Layer
Implement Catalog and Business services:
HTTP Layer
Register routes in routes/web.php: use App\Http\Controllers\ ModuleController ;
Route :: middleware ([ 'auth' ]) -> group ( function () {
// Standard CRUD routes
Route :: resource ( 'modules' , ModuleController :: class );
// Bulk actions
Route :: post ( 'modules/bulk' , [ ModuleController :: class , 'bulk' ])
-> name ( 'modules.bulk' );
// Export
Route :: get ( 'modules/export' , [ ModuleController :: class , 'export' ])
-> name ( 'modules.export' );
// Trash management
Route :: get ( 'modules/eliminados' , [ ModuleController :: class , 'eliminados' ])
-> name ( 'modules.eliminados' );
Route :: post ( 'modules/{id}/restore' , [ ModuleController :: class , 'restore' ])
-> name ( 'modules.restore' );
Route :: delete ( 'modules/{id}/purge' , [ ModuleController :: class , 'purge' ])
-> name ( 'modules.purge' );
});
app/Http/Controllers/ModuleController.php
<? php
namespace App\Http\Controllers ;
use App\Http\Requests\Module\ { StoreModuleRequest , UpdateModuleRequest , BulkModuleRequest };
use App\Models\ Module ;
use App\Services\Module\ { ModuleService , ModuleCatalogService };
use App\Filters\Module\ ModuleFilters ;
use App\Tables\ ModuleTable ;
use Illuminate\Http\ Request ;
class ModuleController extends Controller
{
public function __construct (
protected ModuleService $service ,
protected ModuleCatalogService $catalogService
) {}
/**
* Display the module index with filters
*/
public function index ( Request $request )
{
$visibleColumns = $request -> input ( 'columns' , ModuleTable :: defaultDesktop ());
$perPage = $request -> input ( 'per_page' , 10 );
// Apply filter pipeline
$modules = ( new ModuleFilters ( $request ))
-> apply ( Module :: query () -> withIndexRelations ())
-> latest ()
-> paginate ( $perPage )
-> withQueryString ();
$catalogs = $this -> catalogService -> getForFilters ();
if ( $request -> ajax ()) {
return view ( 'modules.partials.table' , [
'items' => $modules ,
'visibleColumns' => $visibleColumns ,
'allColumns' => ModuleTable :: allColumns (),
]) -> render ();
}
return view ( 'modules.index' , array_merge (
[ 'items' => $modules , 'visibleColumns' => $visibleColumns ],
$catalogs
));
}
/**
* Show the form for creating a new module
*/
public function create ()
{
return view ( 'modules.create' , $this -> catalogService -> getForForm ());
}
/**
* Store a new module
*/
public function store ( StoreModuleRequest $request )
{
$module = $this -> service -> create ( $request -> validated ());
return redirect ()
-> route ( 'modules.index' )
-> with ( 'success' , "Module { $module -> name } created successfully." );
}
/**
* Show the form for editing a module
*/
public function edit ( Module $module )
{
return view ( 'modules.edit' , array_merge (
[ 'module' => $module ],
$this -> catalogService -> getForForm ()
));
}
/**
* Update the specified module
*/
public function update ( UpdateModuleRequest $request , Module $module )
{
$this -> service -> update ( $module , $request -> validated ());
return redirect ()
-> route ( 'modules.index' )
-> with ( 'success' , "Module { $module -> name } updated successfully." );
}
/**
* Soft delete a module
*/
public function destroy ( Module $module )
{
$module -> delete ();
return redirect ()
-> route ( 'modules.index' )
-> with ( 'success' , 'Module moved to trash.' );
}
/**
* Handle bulk actions
*/
public function bulk ( BulkModuleRequest $request )
{
$count = $this -> service -> performBulkAction (
$request -> input ( 'ids' ),
$request -> input ( 'action' ),
$request -> input ( 'value' )
);
$label = $this -> service -> getActionLabel ( $request -> input ( 'action' ));
return back () -> with ( 'success' , "{ $count } modules { $label }." );
}
}
Frontend Views
Frontend views should include:
Index view with AJAX table and filter components
JavaScript configuration for window.filterSources to render filter chips
Create/Edit forms populated by CatalogService data
Standard Flow Example
Here’s how a typical store operation flows through the architecture:
/**
* Store a new module
*
* StoreModuleRequest handles:
* 1. Permission authorization (authorize method)
* 2. Data validation (rules method)
*/
public function store ( StoreModuleRequest $request , ModuleService $service )
{
// Service centralizes creation and database logic
$module = $service -> create ( $request -> validated ());
return redirect ()
-> route ( 'modules.index' )
-> with ( 'success' , "Module { $module -> name } created successfully." );
}
Key Principles
Skinny Controllers Controllers should only orchestrate. No business logic, no DB queries, no complex validations.
Single Responsibility Each class does one thing well. Filters filter, Services contain business logic, Requests validate.
Consistency Every module follows the same structure. Learn once, apply everywhere.
Testability Services are easy to unit test. Filters can be tested independently.
Real-World Example
Let’s look at how the Sales module implements this pattern:
app/Http/Controllers/Sales/SaleController.php
app/Services/Sales/SalesServices/SaleService.php
app/Http/Requests/Sales/StoreSaleRequest.php
public function store ( StoreSaleRequest $request )
{
try {
$sale = $this -> service -> create ( $request -> validated ());
return redirect ()
-> route ( 'sales.index' )
-> with ( 'success' , "Sale #{ $sale -> number } registered successfully." );
} catch ( Exception $e ) {
return back () -> withInput () -> with ( 'error' , "Error: " . $e -> getMessage ());
}
}
Next Steps
Catalog Services Learn how to provide data for dropdowns and filters
Business Services Implement business logic and complex operations
Form Requests Master validation and authorization patterns
Architecture Guide Understand the complete system architecture