Keep your controllers thin. Put business rules in Services and database work in Repositories/Models. Always validate with Form Requests. Use transactions for multi-step writes. This setup is easy to test, easy to change, and ready for ERP scale.
Why this matters
In ERP projects, one module touches another. If you mix database code, validation, and business rules inside controllers, changes become risky. A small change in one place can break many screens.
A clean structure helps:
- Controller: handles HTTP only.
- Service: holds business rules.
- Repository: talks to the database (via Eloquent).
- Model: defines table mapping and relationships.
- Form Request: validates incoming data.
Recommended folder layout
app/
├─ Http/
│ ├─ Controllers/
│ │ └─ UserController.php
│ └─ Requests/
│ └─ StoreUserRequest.php
├─ Models/
│ └─ User.php
├─ Repositories/
│ └─ UserRepository.php
└─ Services/
└─ UserService.php
You can follow the same pattern for every module: Employees, Vendors, Products, Orders, Invoices, etc.
Step-by-step: User module (CRUD)
1) Migration (database as last line of defense)
// database/migrations/2025_09_03_000000_create_users_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('users', function (Blueprint $table) {
$table->id();
$table->string('name', 255);
$table->string('email')->unique();
$table->string('password');
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void {
Schema::dropIfExists('users');
}
};
Why: Unique and not-null constraints protect your data even if a bug slips past validation.
2) Model (simple and clear)
// app/Models/User.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class User extends Model
{
use SoftDeletes;
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password'];
protected $casts = [
'email_verified_at' => 'datetime',
];
}
3) Repository (all DB calls live here)
// app/Repositories/UserRepository.php
namespace App\Repositories;
use App\Models\User;
class UserRepository
{
public function all()
{
return User::orderByDesc('id')->paginate(20);
}
public function find(int $id): User
{
return User::findOrFail($id);
}
public function create(array $data): User
{
return User::create($data);
}
public function update(User $user, array $data): User
{
$user->update($data);
return $user;
}
public function delete(User $user): bool
{
return (bool) $user->delete();
}
}
4) Service (business rules + transactions)
// app/Services/UserService.php
namespace App\Services;
use App\Repositories\UserRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class UserService
{
public function __construct(private UserRepository $users) {}
public function list()
{
return $this->users->all();
}
public function create(array $data)
{
// business rules go here
$data['password'] = Hash::make($data['password']);
return DB::transaction(function () use ($data) {
// If you need to write to multiple tables, do it here.
return $this->users->create($data);
});
}
public function update(int $id, array $data)
{
$user = $this->users->find($id);
if (!empty($data['password'])) {
$data['password'] = Hash::make($data['password']);
} else {
unset($data['password']);
}
return DB::transaction(function () use ($user, $data) {
return $this->users->update($user, $data);
});
}
public function delete(int $id)
{
$user = $this->users->find($id);
return $this->users->delete($user);
}
}
Use
DB::transaction()
whenever you do two or more writes that must succeed or fail together (classic ERP need).
5) Form Request (industry standard validation)
// app/Http/Requests/StoreUserRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return true; // add role/permission checks if needed
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|min:8',
];
}
}
Create an update request too (email unique except current user, password optional).
// app/Http/Requests/UpdateUserRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateUserRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'name' => 'sometimes|required|string|max:255',
'email' => [
'sometimes','required','email',
Rule::unique('users', 'email')->ignore($this->route('id')),
],
'password' => 'nullable|min:8',
];
}
}
6) Controller (thin and readable)
// app/Http/Controllers/UserController.php
namespace App\Http\Controllers;
use App\Services\UserService;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
class UserController extends Controller
{
public function __construct(private UserService $service) {}
public function index()
{
return response()->json($this->service->list());
}
public function store(StoreUserRequest $request)
{
$user = $this->service->create($request->validated());
return response()->json($user, 201);
}
public function update(UpdateUserRequest $request, int $id)
{
$user = $this->service->update($id, $request->validated());
return response()->json($user);
}
public function destroy(int $id)
{
$this->service->delete($id);
return response()->json(null, 204);
}
}
7) Routes
// routes/api.php
use App\Http\Controllers\UserController;
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
Route::put('/users/{id}', [UserController::class, 'update']);
Route::delete('/users/{id}', [UserController::class, 'destroy']);
Validation vs Database constraints (short note)
- Validate first (Form Request). This gives nice error messages (HTTP 422) and blocks bad data early.
- Keep constraints (unique, not null, foreign keys). These guard your database if something slips through.
Both layers together give you strong data quality.
Patterns that scale well in ERP
DTOs or Arrays?
Arrays are fine for simple cases. DTOs (data objects) help when payloads grow big.Events & Jobs
Send emails, logs, or sync tasks as queued jobs after commits.
Example:UserCreated
event → listener dispatchesSendWelcomeEmail
.API Resources
Use LaravelJsonResource
to format consistent API responses.Soft Deletes
Enable for important tables. ERP users often “restore” records.Global Rules
Put cross-cutting rules (e.g., multi-tenant scopes, company_id filters) in global scopes or services.
A quick ERP example: create an Order with items (transaction)
// app/Services/OrderService.php (sketch)
public function createOrder(array $data)
{
return DB::transaction(function () use ($data) {
$order = $this->orders->create([
'customer_id' => $data['customer_id'],
'total' => 0,
]);
$total = 0;
foreach ($data['items'] as $item) {
$line = $this->orderLines->create([
'order_id' => $order->id,
'product_id'=> $item['product_id'],
'qty' => $item['qty'],
'price' => $item['price'],
'subtotal' => $item['qty'] * $item['price'],
]);
$total += $line->subtotal;
}
$this->orders->update($order, ['total' => $total]);
// fire events if needed
return $order->fresh('lines');
});
}
If any insert fails, the whole order is rolled back. This is key for ERP integrity.
Testing (fast confidence)
- Unit test services with repository fakes or an in-memory database.
- Feature test controllers to check validation and responses.
Example (very short):
public function test_it_creates_user_with_valid_payload()
{
$payload = [
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'secret1234',
];
$this->postJson('/api/users', $payload)
->assertCreated()
->assertJsonPath('email', 'alice@example.com');
}
Common mistakes to avoid
- Putting DB queries directly in controllers.
- Skipping Form Requests and relying only on try/catch.
- Mixing HTTP concerns with business logic.
- Not using transactions for multi-table writes.
- No pagination on list endpoints.
- No unique/foreign key constraints in migrations.
Top comments (0)