DEV Community

Cover image for Lessons I Learned After Reviewing 30+ Junior Laravel Projects
kalam714
kalam714

Posted on

Lessons I Learned After Reviewing 30+ Junior Laravel Projects

After reviewing over 30 Laravel projects from interns and junior developers, I realized most of them made the same mistakes — not because they were lazy, but because no one explained the fundamentals clearly.


How I Got Here

Two years ago, I started mentoring junior developers at my company. Part of my role involved reviewing Laravel projects built by interns, fresh bootcamp graduates, and self-taught developers looking to break into the industry. What started as casual code reviews quickly became eye-opening.

Project after project, I saw the same patterns emerge. The same architectural mistakes. The same misunderstandings about Laravel's core features. The same heavy controllers doing everything from validation to email sending to business logic.

At first, I thought it was a coincidence. But after the 15th project, I realized: these weren't individual mistakes. They were gaps in foundational knowledge.

In this article, I'm sharing the most important lessons I learned from reviewing these projects. If you're a junior Laravel developer, consider this your roadmap to writing cleaner, more maintainable code. If you're teaching Laravel, these are the concepts worth emphasizing from day one.


Lesson 1 — Folder Structure Isn't Decoration

The first thing I noticed was how juniors treated Laravel's folder structure like a suggestion rather than a framework.

Everything lived inside app/Http/Controllers or app/Models. Controllers were massive — sometimes 500+ lines. Business logic, email sending, PDF generation, payment processing — all jammed into controller methods.

Why does this matter?

Laravel gives you a flexible structure because real applications need layers. Controllers should coordinate. Services should contain business logic. Repositories should handle data access. Form Requests should validate.

When everything lives in controllers, your code becomes:

  • Hard to test (can't mock dependencies easily)
  • Hard to reuse (logic is tied to HTTP requests)
  • Hard to maintain (changing one feature breaks unrelated code)

Bad structure:

app/
  Http/
    Controllers/
      UserController.php (500 lines of everything)
  Models/
    User.php
Enter fullscreen mode Exit fullscreen mode

Better structure:

app/
  Http/
    Controllers/
      UserController.php (thin, delegates work)
    Requests/
      UserRequest.php
  Services/
    UserService.php
  Repositories/
    UserRepository.php
  Models/
    User.php
Enter fullscreen mode Exit fullscreen mode

Here's what that looks like in code:

// ❌ Bad: Everything in the controller
public function store(Request $request) {
    $user = new User();
    $user->name = $request->name;
    $user->email = $request->email;
    $user->password = Hash::make($request->password);
    $user->save();

    Mail::to($user)->send(new WelcomeMail());

    Log::info('User created: ' . $user->id);

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

// ✅ Better: Delegate to a service
public function store(UserRequest $request, UserService $service) {
    $user = $service->createUser($request->validated());
    return redirect()->route('dashboard');
}
Enter fullscreen mode Exit fullscreen mode

My advice: Start separating concerns early, even in small projects. Future you will thank present you.


Lesson 2 — Validation Belongs in Form Requests

I saw this pattern in almost every project:

public function store(Request $request) {
    $request->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:users',
        'password' => 'required|min:8|confirmed',
    ]);
    // ... rest of logic
}
Enter fullscreen mode Exit fullscreen mode

It works, sure. But it clutters your controllers and makes validation logic impossible to reuse.

Why Form Requests are better:

  1. They clean up your controllers
  2. They're reusable across multiple methods
  3. They support custom authorization logic
  4. They keep validation rules in one place
  5. They make testing easier

Creating one is simple:

php artisan make:request UserRequest
Enter fullscreen mode Exit fullscreen mode

Then use it:

// app/Http/Requests/UserRequest.php
class UserRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users',
            'password' => 'required|min:8|confirmed',
        ];
    }

    public function messages()
    {
        return [
            'email.unique' => 'This email is already registered.',
        ];
    }
}

// Controller
public function store(UserRequest $request) {
    // Automatically validated!
    $data = $request->validated();
}
Enter fullscreen mode Exit fullscreen mode

Bonus tip: You can even handle different rules for create vs update in the same FormRequest by checking the route:

public function rules()
{
    $userId = $this->route('user');

    return [
        'email' => 'required|email|unique:users,email,' . $userId,
    ];
}
Enter fullscreen mode Exit fullscreen mode

Lesson 3 — Understand Relationships Deeply

This one hurt to watch. Many juniors guessed at relationship types instead of understanding them.

I saw code like:

// In User model
public function posts() {
    return $this->belongsTo(Post::class); // Wrong direction!
}
Enter fullscreen mode Exit fullscreen mode

Or worse, they'd avoid relationships entirely and write manual queries everywhere.

The fundamentals:

  • hasOne / hasMany: "I own one/many of these"
  • belongsTo: "I belong to that"
  • belongsToMany: "We both reference each other through a pivot"

A User has many Posts. A Post belongs to a User. It's that simple.

The bigger problem: N+1 queries

This was everywhere:

// ❌ Bad: N+1 problem
$users = User::all(); // 1 query
foreach ($users as $user) {
    echo $user->posts->count(); // +N queries (one per user)
}
// Total: 1 + N queries
Enter fullscreen mode Exit fullscreen mode

If you have 100 users, that's 101 queries. Your database will cry.

The fix: Eager loading

// ✅ Good: eager load
$users = User::withCount('posts')->get(); // 1 query
foreach ($users as $user) {
    echo $user->posts_count; // No extra queries
}
Enter fullscreen mode Exit fullscreen mode

Or if you need the actual posts:

$users = User::with('posts')->get(); // 2 queries total
Enter fullscreen mode Exit fullscreen mode

My advice: Install Laravel Debugbar early in development. Watch your query count. If you see numbers climbing into the hundreds, you have an N+1 problem.


Lesson 4 — Stop Abusing Static Methods and Facades

Many juniors wrote code like this:

public function send() {
    DB::table('messages')->insert([...]);
    Auth::user()->notify(...);
    Mail::to($user)->send(...);
    Cache::put('key', $value);
}
Enter fullscreen mode Exit fullscreen mode

Facades everywhere. Impossible to mock. Impossible to test. Tightly coupled to Laravel's global state.

Why this matters:

Facades are convenient, but they hide dependencies. When you write tests, you can't easily swap out Mail for a fake version without using static mocking (which is fragile).

The better way: Dependency Injection

// Controller
public function __construct(
    private MailService $mailService,
    private UserRepository $users
) {}

public function send(Request $request) {
    $user = $this->users->find($request->user_id);
    $this->mailService->sendWelcome($user);
}
Enter fullscreen mode Exit fullscreen mode

Now your dependencies are explicit. Testing becomes trivial — just pass in mocks.

When facades are fine:

In controllers, routes, or quick prototypes, facades are okay. But keep them out of your service layer and business logic. That's where you need clean, testable code.


Lesson 5 — Don't Ignore Error Handling

This was painful to see:

public function show($id) {
    $order = Order::find($id);
    return view('order', compact('order'));
}
Enter fullscreen mode Exit fullscreen mode

What happens when $id doesn't exist? $order is null, and the view crashes with "Trying to get property of non-object."

Better approach:

public function show($id) {
    $order = Order::findOrFail($id); // Throws 404 automatically
    return view('order', compact('order'));
}
Enter fullscreen mode Exit fullscreen mode

Even better with explicit error handling:

try {
    $order = $this->orderService->find($id);
    return view('order', compact('order'));
} catch (ModelNotFoundException $e) {
    return back()->withErrors('Order not found!');
}
Enter fullscreen mode Exit fullscreen mode

Custom exceptions are your friend:

// app/Exceptions/PaymentFailedException.php
class PaymentFailedException extends Exception
{
    public function render()
    {
        return response()->json([
            'error' => 'Payment processing failed'
        ], 402);
    }
}

// In your service
throw new PaymentFailedException();
Enter fullscreen mode Exit fullscreen mode

Handle globally in app/Exceptions/Handler.php for consistent error responses.


Lesson 6 — Learn Query Optimization Early

I once reviewed a project that pulled every user into memory just to count active ones:

// ❌ Bad: loads entire table
$users = User::all();
$activeCount = $users->where('active', true)->count();
Enter fullscreen mode Exit fullscreen mode

If you have 50,000 users, you just loaded 50,000 records into PHP memory to count them.

The fix:

// ✅ Good: query runs in database
$activeCount = User::where('active', true)->count();
Enter fullscreen mode Exit fullscreen mode

Other common mistakes:

  1. Not selecting specific columns:
// ❌ Loads all columns
$users = User::all();

// ✅ Only loads what you need
$users = User::select('id', 'name', 'email')->get();
Enter fullscreen mode Exit fullscreen mode
  1. Not using chunk() for large datasets:
// ✅ Processes in batches
User::chunk(100, function ($users) {
    foreach ($users as $user) {
        // process
    }
});
Enter fullscreen mode Exit fullscreen mode
  1. Missing database indexes:
// In a migration
$table->index('email');
$table->index(['user_id', 'created_at']);
Enter fullscreen mode Exit fullscreen mode

My advice: Install Laravel Debugbar. Watch your query execution time. Anything over 100ms deserves investigation.


Lesson 7 — Stop Mixing Logic and Presentation

Controllers should never format data. Yet I saw this constantly:

public function index() {
    $users = User::all();

    foreach ($users as $user) {
        $user->formatted_name = strtoupper($user->name);
        $user->status_label = $user->active ? 'Active' : 'Inactive';
    }

    return response()->json($users);
}
Enter fullscreen mode Exit fullscreen mode

This couples your API responses to your controller logic. Want to reuse this elsewhere? Good luck.

The solution: API Resources

php artisan make:resource UserResource
Enter fullscreen mode Exit fullscreen mode
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => strtoupper($this->name),
            'email' => $this->email,
            'status' => $this->active ? 'Active' : 'Inactive',
            'created' => $this->created_at->diffForHumans(),
        ];
    }
}

// Controller
public function index() {
    return UserResource::collection(User::all());
}
Enter fullscreen mode Exit fullscreen mode

Clean. Reusable. Testable.

For collections, consider ResourceCollection for additional metadata:

return new UserCollection(User::paginate(10));
Enter fullscreen mode Exit fullscreen mode

Lesson 8 — Understand the Request Lifecycle

Many juniors treated Laravel like magic. They didn't know how a request actually flowed through the framework.

When I asked, "What happens between hitting a URL and your controller method running?" — blank stares.

Here's the simplified flow:

  1. public/index.php receives the request
  2. Kernel bootstraps the application
  3. Middleware stack processes the request
  4. Router matches the URL to a route
  5. Controller method executes
  6. Response travels back through middleware
  7. Browser receives the response

Why this matters:

When your AI-generated code breaks, you need to understand where to look. Is it middleware? Is it route caching? Is it a service provider not registered?

Without understanding the lifecycle, debugging becomes impossible.

My advice: Read Laravel's documentation section on the request lifecycle. It's short, but it'll save you hours of confusion.


Lesson 9 — Learn to Write Tests (Even Simple Ones)

Out of 30+ projects, maybe 3 had any tests at all.

I get it — tests feel like extra work. But here's what I learned: tests save you time during refactoring.

Start simple:

php artisan make:test UserRegistrationTest
Enter fullscreen mode Exit fullscreen mode
// tests/Feature/UserRegistrationTest.php
public function test_user_can_register()
{
    $response = $this->post('/register', [
        'name' => 'John Doe',
        'email' => 'john@example.com',
        'password' => 'password123',
        'password_confirmation' => 'password123',
    ]);

    $response->assertRedirect('/dashboard');
    $this->assertDatabaseHas('users', [
        'email' => 'john@example.com'
    ]);
}
Enter fullscreen mode Exit fullscreen mode

That's it. Run it with:

php artisan test
Enter fullscreen mode Exit fullscreen mode

Why tests matter:

  • You'll refactor with confidence
  • You'll catch bugs before production
  • You'll document how your code should behave
  • You'll write better code (testable code is usually better code)

You don't need 100% coverage. Start with testing critical paths: authentication, payments, core business logic.


Lesson 10 — Be Careful With AI Tools

This is the newest pattern I've seen. Juniors using ChatGPT or Claude to generate entire controllers, models, and migrations.

AI is incredible. I use it daily. But here's the problem: AI doesn't always follow Laravel best practices.

I've seen AI-generated code that:

  • Uses raw SQL instead of Eloquent
  • Skips Form Requests entirely
  • Enables mass assignment without $fillable or $guarded
  • Creates N+1 query nightmares
  • Ignores Laravel conventions

My advice:

Use AI as a starting point, not the final answer. When AI gives you code:

  1. Read it line by line
  2. Understand what each part does
  3. Check if it follows Laravel conventions
  4. Test it before deploying

AI is a tool that amplifies your skills. If your fundamentals are weak, AI will amplify those weaknesses too.

Learn the framework deeply first. Then let AI accelerate you.


Conclusion

After reviewing 30+ Laravel projects, I learned something important: most juniors aren't making mistakes because they're careless. They're making mistakes because no one taught them the "why" behind the "how."

Laravel is an elegant framework, but it's easy to misuse. Controllers can become dumping grounds. Facades can create untestable code. Relationships can generate thousands of unnecessary queries.

Here's my challenge to you:

Go back to your old Laravel projects. Pick one. Refactor it using the principles in this article:

  • Separate your concerns
  • Use Form Requests
  • Optimize your queries
  • Inject dependencies
  • Write a few tests

You'll be amazed at how much cleaner your code becomes.

Remember: clean code isn't about perfection — it's about empathy for the next developer who reads it, even if that developer is you in six months.


If this article helped you, consider applying these lessons to your current project this week. Pick one lesson, implement it, and notice the difference. Small improvements compound into mastery.

Happy coding!

Top comments (3)

Collapse
 
xwero profile image
david duymelinck • Edited

While most lessons are good observations, I especially like the numbers 4, 5, 8, 9 and 10, I see some of the lessons from a different perspective.

The controller doesn't have to thin. The example of a controller method is a code smell for me because most of the times the code from the controller is moved to the service method as is. Which means you created an abstraction with low or no extra value.
A controller method that stands my smell test can look like

public function store(Request $request, UserValidationService $validation, UserRepository $repository)
{
    // returns Error or User model
    // throwing errors clutters the code, returning an Error type makes the code easier to follow.
    $validationResult = $validation->create($request);

    if(validationResult instanceof Error) {
       // do something
    }

    $storageResult = $repository->create($validationResult);

    if($storageResult instanceof Error)
    {
        // do something
    }
    // Handles sending the mail and logging.
    // We care less about those action to complete, so no error checking is needed.
   // if you care about the action make it explicit. 
    UserCreated::dispatch($storageResult);

    return redirect()->route('dashboard');
}
Enter fullscreen mode Exit fullscreen mode

The controller method is thinner, but it has a easy to follow flow of actions. You don't want to hide this in layers of abstractions.

Regarding the FormRequest and JsonResource classes, I think they hide logic you might want to control.
Both classes control the redirection in case of errors. Which is fine if that is the way you want errors to be handled. But as a beginner you have to be aware the classes aren't just a validator and transformer abstraction respectively.

For the database I'm becoming less and less a fan of the using an ORM. I think query languages are less prone to mistakes than doing it with an ORM abstraction layer. You also need to have two mental models in your head, the ORM model and the query language model.
For complex queries there are a lot of cases where raw SQL is necessary, so why not use raw SQL for the easy queries?
This doesn't exclude the use of models. It main difference is that the models aren't tied to a specific ORM.

I think any solution you build should use both the power of PHP and design patterns provide. Don't blindly follow the code or design pattern examples, make it your own to come to the best code for the feature/problem in the specific application.

Collapse
 
kalam714 profile image
kalam714

Thank you for your thoughtful feedback . I appreciate your perspective and would like to address some of the points you've raised.

On Thin Controllers and Service Abstraction

You suggest that controllers don't always need to be thin and that moving code to service methods can sometimes create unnecessary abstraction. I understand your point that if the logic remains unchanged, the abstraction may add little value. However, I believe that separating concerns can lead to more maintainable and testable code. Here's an example of how I approach it:

public function store(Request $request, UserValidationService $validation, UserRepository $repository)
{
    $validationResult = $validation->create($request);

    if ($validationResult instanceof Error) {
        // Handle validation error
    }

    $storageResult = $repository->create($validationResult);

    if ($storageResult instanceof Error) {
        // Handle storage error
    }

    UserCreated::dispatch($storageResult);

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

Enter fullscreen mode Exit fullscreen mode

In this approach, the controller delegates responsibilities to the service and repository layers, keeping the code organized and focused on its primary task.

On FormRequest and JsonResource Classes

You raise a valid concern about FormRequest and JsonResource classes potentially hiding logic that developers might want to control. While these classes offer convenience and consistency, I agree that it's important to understand their behavior, especially regarding automatic redirection on validation errors. Being aware of this behavior allows developers to make informed decisions about error handling in their applications.

On ORM vs. Raw SQL

Your preference for using raw SQL over ORM for certain queries is understandable. Raw SQL can offer more control and potentially better performance for complex queries. However, I believe that ORMs like Eloquent provide a balance between ease of use and flexibility. It's essential to choose the right tool for the task at hand, and in some cases, raw SQL may be the better option.

On Applying Design Patterns Thoughtfully

I wholeheartedly agree with your point about not blindly following design patterns. It's crucial to understand the problem at hand and apply design patterns where they add value. Custom solutions often lead to more efficient and maintainable code, as they are tailored to the specific needs of the application.

Thank you again for your insights. I believe that discussions like this contribute to the growth of the developer community, and I look forward to further exchanges of ideas.

Collapse
 
xwero profile image
david duymelinck

I don't mind that you use AI, but don't use my code as your example.