Nobody Likes Fat Models

You know what drives me crazy? These Laravel tutorials telling you “Fat Models, Skinny Controllers” like it’s the Ten Commandments. Oh really? FAT MODELS? You want me to shove EVERYTHING into my User model?

Let me tell you something - your User.php is 2,000 lines long. TWO THOUSAND LINES. That’s not a model, that’s a NOVEL! You got methods in there like sendWelcomeEmail(), processRefund(), generatePdfReport(), validateCreditCard() - what are you DOING?!

Your model is supposed to represent a database table. That’s IT. It’s not a Swiss Army knife. It’s not your mother. It doesn’t need to do EVERYTHING for you!

The Crime Scene

Here’s what I see in codebases all the time, and it makes me want to scream:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    // 47 relationship methods...

    public function sendWelcomeEmail(): void
    {
        // Sending emails? Really?
    }

    public function processPayment(array $paymentData): bool
    {
        // Processing payments in a MODEL?
    }

    public function generateMonthlyReport(): string
    {
        // Generating reports??
    }

    public function uploadAvatar(UploadedFile $file): void
    {
        // File uploads???
    }

    public function notifyAdmins(): void
    {
        // More email stuff????
    }

    // Another 200 methods...
}

You see this? This is INSANE. This model has more responsibilities than a single parent working three jobs! And you know what happens when your code has too many responsibilities? The same thing that happens to that parent - it breaks down and cries in the database transaction!

”But It’s Convenient!”

Oh, I love this excuse. “It’s convenient to have everything in the model!”

You know what else is convenient? Fast food. And we all know how THAT works out for you. Yeah, it’s convenient NOW, but six months from now when you’re trying to test this thing and you need to mock fourteen different services just to instantiate a User model, you’re gonna be real SORRY about your “convenience.”

“Oh, but I can just call $user->sendWelcomeEmail() anywhere!”

NO. Stop it. That’s not how this works. That’s not how ANY of this works!

The Intervention: Actions

Listen, I’m gonna help you out here. You need ACTIONS. Single-purpose classes that do ONE thing. And you know what? They’re TESTABLE. They’re READABLE. They don’t make me want to throw my laptop out the window.

Here’s what your code SHOULD look like:

<?php

namespace App\Actions\User;

use App\Models\User;
use App\Notifications\WelcomeNotification;
use Illuminate\Support\Facades\Notification;

final readonly class SendWelcomeEmail
{
    public function execute(User $user): void
    {
        Notification::send($user, new WelcomeNotification());
    }
}

Look at that. Beautiful. Clean. Does ONE thing. Uncle Bob is smiling from heaven right now. (He’s not dead, but he’s smiling anyway.)

You want to use it? Here:

<?php

namespace App\Http\Controllers;

use App\Actions\User\SendWelcomeEmail;
use App\Models\User;

final class UserController
{
    public function __construct(
        private readonly SendWelcomeEmail $sendWelcomeEmail
    ) {}

    public function store(StoreUserRequest $request): RedirectResponse
    {
        $user = User::create($request->validated());

        $this->sendWelcomeEmail->execute($user);

        return redirect()->route('dashboard');
    }
}

See how CLEAR that is? You know exactly what’s happening. No mysteries. No surprises. No “let me dig through 2,000 lines to find where this email is being sent.”

Wait, What About Validation?

Oh, you noticed we’re using StoreUserRequest instead of the regular Request class? GOOD. You’re paying attention.

Here’s the beautiful thing about Form Requests that’s gonna blow your mind: The validation happens BEFORE your controller method even gets called.

Let me say that again for the people in the back: IF THE VALIDATION FAILS, YOUR STORE METHOD NEVER RUNS.

public function store(StoreUserRequest $request): RedirectResponse
{
    // If you're reading this line of code during execution,
    // that means validation ALREADY PASSED.
    // Laravel already checked everything.
    // The data is clean. The data is valid.
    // You don't need to validate ANYTHING here!

    $user = User::create($request->validated());

    $this->sendWelcomeEmail->execute($user);

    return redirect()->route('dashboard');
}

So you don’t need to test $request->validated() in your controller tests. Why? Because Laravel GUARANTEES it. If validation failed, this code doesn’t run. It’s that simple.

What DOES happen when validation fails?

  • Regular requests: Laravel redirects back with errors
  • AJAX requests: Returns 422 with JSON errors
  • Your controller method: Doesn’t execute AT ALL

It’s like having a bouncer at a club. If you’re not on the list, you’re not getting in. The bouncer doesn’t let you in and THEN check if you’re on the list. That would be STUPID.

Your Form Request class looks like this:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreUserRequest extends FormRequest
{
    public function authorize(): bool
    {
        // If this returns false, user gets 403
        // Controller method NEVER runs
        return true;
    }

    public function rules(): array
    {
        // If validation fails, user gets redirected back
        // Controller method NEVER runs
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'unique:users'],
            'password' => ['required', 'string', 'min:8'],
        ];
    }
}

See how clean that separation is? Validation logic lives in ONE place. Not scattered across controllers. Not mixed with business logic. ONE. PLACE.

”What About Payment Processing?”

Oh, you wanna put payment processing in your model? Are you OUT OF YOUR MIND?

<?php

namespace App\Actions\Payment;

use App\Models\User;
use App\Services\PaymentGateway;
use App\Exceptions\PaymentFailedException;

final readonly class ProcessUserPayment
{
    public function __construct(
        private PaymentGateway $gateway
    ) {}

    public function execute(User $user, int $amountInCents): PaymentResult
    {
        $result = $this->gateway->charge(
            customerId: $user->stripe_customer_id,
            amount: $amountInCents
        );

        if (!$result->successful) {
            throw new PaymentFailedException($result->errorMessage);
        }

        return $result;
    }
}

Look at this beautiful piece of code! It’s focused. It’s testable. You can mock the payment gateway. You can test error handling. You don’t need a REAL user from the database just to test if your payment logic works!

Data Transfer Objects: Your New Best Friend

And while we’re at it, stop passing arrays around like it’s 2010. Use DTOs. They’re type-safe. They’re explicit. They don’t make me want to cry.

<?php

namespace App\DataTransferObjects;

final readonly class CreateUserData
{
    public function __construct(
        public string $name,
        public string $email,
        public string $password,
        public ?string $phoneNumber = null
    ) {}

    public static function fromRequest(array $data): self
    {
        return new self(
            name: $data['name'],
            email: $data['email'],
            password: bcrypt($data['password']),
            phoneNumber: $data['phone_number'] ?? null
        );
    }
}

Now your action:

<?php

namespace App\Actions\User;

use App\Models\User;
use App\DataTransferObjects\CreateUserData;

final readonly class CreateUser
{
    public function execute(CreateUserData $data): User
    {
        return User::create([
            'name' => $data->name,
            'email' => $data->email,
            'password' => $data->password,
            'phone_number' => $data->phoneNumber,
        ]);
    }
}

Clean. Simple. Your IDE can autocomplete. Your static analyzer won’t yell at you. It’s BEAUTIFUL.

Service Classes: For the Complex Stuff

Sometimes you got complex business logic. Multiple steps. Multiple models involved. You know what you don’t do? Shove it in ONE model. You make a SERVICE.

<?php

namespace App\Services;

use App\Models\User;
use App\Actions\User\CreateUser;
use App\Actions\User\SendWelcomeEmail;
use App\Actions\User\AssignDefaultRole;
use App\DataTransferObjects\CreateUserData;
use Illuminate\Support\Facades\DB;

final readonly class UserRegistrationService
{
    public function __construct(
        private CreateUser $createUser,
        private SendWelcomeEmail $sendWelcomeEmail,
        private AssignDefaultRole $assignDefaultRole
    ) {}

    public function register(CreateUserData $data): User
    {
        return DB::transaction(function () use ($data) {
            $user = $this->createUser->execute($data);

            $this->assignDefaultRole->execute($user);
            $this->sendWelcomeEmail->execute($user);

            return $user;
        });
    }
}

Look at that orchestration! It’s like conducting a symphony, except instead of music, it’s CODE, and instead of making people cry from beauty, it’s making them cry from… well, also beauty, but CLEAN CODE beauty!

What SHOULD Be In Your Model?

You’re probably thinking, “Okay smartass, what SHOULD I put in my model then?”

GREAT QUESTION. Here’s the list:

  1. Eloquent relationships - That’s what they’re FOR
  2. Accessors and mutators - Data formatting
  3. Scopes - Query filtering
  4. Casts - Type casting
  5. That’s IT

Here’s a model done RIGHT:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;

class User extends Model
{
    protected $fillable = [
        'name',
        'email',
        'password',
        'phone_number',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class);
    }

    public function scopeVerified(Builder $query): Builder
    {
        return $query->whereNotNull('email_verified_at');
    }

    protected function fullName(): Attribute
    {
        return Attribute::make(
            get: fn () => "{$this->first_name} {$this->last_name}"
        );
    }
}

THAT’S a model! It’s lean. It’s focused. It represents your database table and provides Eloquent conveniences. It doesn’t try to be a superhero. It knows its lane and it STAYS in it.

The Real Talk

Look, I get it. When you’re starting out, it’s EASIER to just throw everything in the model. I’ve been there. We’ve ALL been there. But you know what’s NOT easier? Maintaining that garbage six months from now when you can’t remember what anything does.

You know what’s NOT easier? Testing a model that has dependencies on seventeen different services.

You know what’s NOT easier? Explaining to your team why the User model is responsible for sending emails, processing payments, generating reports, AND making coffee.

The Bottom Line

Your models should be BORING. They should be so boring that you can explain them to your grandmother. “It represents a user in the database, Grandma.” Done. That’s it.

All that other fancy stuff? Actions. Services. Jobs. Listeners. Laravel gives you ALL these tools, and you’re still shoving everything into models like it’s a clown car!

Use the right tool for the job:

  • Actions - Single-purpose operations
  • Services - Complex business logic orchestration
  • Jobs - Background processing
  • Listeners - Event responses
  • Models - Database representation and Eloquent features

That’s it. That’s the tweet. That’s the whole article.

Now go refactor your 2,000-line User model before it gains sentience and takes over your application.


Built with Laravel 12 and tears of frustration from reading too many fat models.