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
Better structure:
app/
Http/
Controllers/
UserController.php (thin, delegates work)
Requests/
UserRequest.php
Services/
UserService.php
Repositories/
UserRepository.php
Models/
User.php
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');
}
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
}
It works, sure. But it clutters your controllers and makes validation logic impossible to reuse.
Why Form Requests are better:
- They clean up your controllers
- They're reusable across multiple methods
- They support custom authorization logic
- They keep validation rules in one place
- They make testing easier
Creating one is simple:
php artisan make:request UserRequest
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();
}
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,
];
}
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!
}
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 Post
s. 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
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
}
Or if you need the actual posts:
$users = User::with('posts')->get(); // 2 queries total
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);
}
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);
}
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'));
}
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'));
}
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!');
}
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();
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();
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();
Other common mistakes:
- Not selecting specific columns:
// ❌ Loads all columns
$users = User::all();
// ✅ Only loads what you need
$users = User::select('id', 'name', 'email')->get();
- Not using chunk() for large datasets:
// ✅ Processes in batches
User::chunk(100, function ($users) {
foreach ($users as $user) {
// process
}
});
- Missing database indexes:
// In a migration
$table->index('email');
$table->index(['user_id', 'created_at']);
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);
}
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
// 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());
}
Clean. Reusable. Testable.
For collections, consider ResourceCollection
for additional metadata:
return new UserCollection(User::paginate(10));
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:
-
public/index.php
receives the request - Kernel bootstraps the application
- Middleware stack processes the request
- Router matches the URL to a route
- Controller method executes
- Response travels back through middleware
- 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
// 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'
]);
}
That's it. Run it with:
php artisan test
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:
- Read it line by line
- Understand what each part does
- Check if it follows Laravel conventions
- 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)
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
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.
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:
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.
I don't mind that you use AI, but don't use my code as your example.