
Chapter 03: Laravel's Developer Experience: Productivity, Conventions and Tools
Overview
In Chapter 02, you learned about modern PHP's evolution—type declarations, JIT compilation, and language features that bring PHP on par with Python. Now it's time to explore Laravel's developer experience tools, which are where Laravel truly shines. If you've worked with Django's manage.py or Flask's CLI tools, you'll find Laravel's Artisan CLI familiar yet more powerful.
This chapter is about productivity and conventions. Laravel follows a "conventions over configuration" philosophy similar to Django, meaning you spend less time configuring and more time building. You'll learn how Laravel's command-line tools, migrations, testing framework, and naming conventions compare to Python equivalents—and where Laravel might surprise you with its developer-friendly features.
By the end of this chapter, you'll understand that Laravel's developer experience tools mirror Python's best practices while adding their own delightful touches. You'll master Artisan CLI (Laravel's manage.py), migrations (Laravel's version of Django migrations), PHPUnit testing (Laravel's pytest), and Laravel's conventions. Most importantly, you'll see how these tools accelerate development in ways that feel familiar yet refreshing.
Prerequisites
Before starting this chapter, you should have:
- Completion of Chapter 02 or equivalent understanding of modern PHP
- Laravel 11.x installed (or ability to follow along with code examples)
- Python experience with Django's
manage.pyor Flask CLI - Familiarity with pytest or unittest
- Basic understanding of database migrations (Django migrations preferred)
- Estimated Time: ~75 minutes
Verify your setup:
# Check PHP version (should show PHP 8.4+)
php --version
# Check Composer is installed
composer --version
# If you have Laravel installed, verify Artisan works
php artisan --version
# Expected output: Laravel Framework 11.x.x (or similar)What You'll Build
By the end of this chapter, you will have:
- Understanding of Artisan CLI commands and how they compare to Django's
manage.py - Knowledge of Laravel migrations workflow vs Django migrations
- PHPUnit test examples comparing to pytest
- Understanding of Laravel conventions vs Django/Flask conventions
- Working code examples demonstrating each tool
- Ability to create custom Artisan commands, migrations, seeders, and tests
- Confidence in Laravel's developer experience tools
Quick Start
Want to see Laravel's developer tools in action right away? Here's a quick comparison:
Django (Python):
# Create a migration
python manage.py makemigrations
# Run migrations
python manage.py migrate
# Run tests
python manage.py testLaravel (PHP):
# Create a migration
php artisan make:migration create_users_table
# Run migrations
php artisan migrate
# Run tests
php artisan testSee the pattern? Laravel's php artisan is Django's python manage.py equivalent. Both provide command-line access to framework functionality, but Laravel's Artisan offers more built-in commands and a more consistent interface. This chapter will show you all the ways Laravel's developer tools compare to Python equivalents.
Objectives
- Master Artisan CLI commands and compare to Python equivalents (Django's
manage.py, Flask CLI) - Understand Laravel migrations vs Django migrations workflow
- Learn PHPUnit testing vs pytest workflows
- Understand Laravel conventions vs Django/Flask conventions
- Use seeders and factories for test data management
- Recognize Laravel's "conventions over configuration" philosophy
- Create custom Artisan commands, migrations, seeders, and tests
- Use Laravel's code generation commands (
make:*) to boost productivity - Manage cache and configuration with Artisan commands
Step 1: Artisan CLI - Laravel's Command-Line Tool (~15 min)
Goal
Understand Laravel's Artisan CLI and how it compares to Django's manage.py and Flask CLI tools.
Actions
Artisan vs manage.py - The Basics
Laravel's Artisan CLI is the equivalent of Django's
manage.py. Both provide command-line access to framework functionality:Django (Python):
bash# List all available commands python manage.py help # Run a specific command python manage.py runserver python manage.py createsuperuser python manage.py shellLaravel (PHP):
bash# List all available commands php artisan list # Run a specific command php artisan serve php artisan make:user php artisan tinkerThe complete Artisan command examples are available in
artisan-commands.php.Common Commands Comparison
Here's how common Django commands map to Laravel:
Django Command Laravel Command Purpose python manage.py runserverphp artisan serveStart development server python manage.py shellphp artisan tinkerInteractive shell python manage.py makemigrationsphp artisan make:migrationCreate migration python manage.py migratephp artisan migrateRun migrations python manage.py testphp artisan testRun tests python manage.py createsuperuserphp artisan make:userCreate admin user python manage.py collectstaticphp artisan storage:linkLink storage python manage.py showmigrationsphp artisan migrate:statusShow migration status python manage.py dbshellphp artisan dbOpen database shell python manage.py shell_plusphp artisan tinkerEnhanced shell with models Creating Custom Commands
Both frameworks allow you to create custom commands:
Django Management Command:
python# filename: management/commands/send_emails.py from django.core.management.base import BaseCommand from django.core.mail import send_mail class Command(BaseCommand): help = 'Send email notifications' def add_arguments(self, parser): parser.add_argument('--count', type=int, default=10) def handle(self, *args, **options): count = options['count'] self.stdout.write(f'Sending {count} emails...') # Send emails logic self.stdout.write(self.style.SUCCESS('Emails sent!'))Laravel Artisan Command:
php# filename: app/Console/Commands/SendEmails.php <?php namespace App\Console\Commands; use Illuminate\Console\Command; class SendEmails extends Command { protected $signature = 'emails:send {--count=10}'; protected $description = 'Send email notifications'; public function handle(): int { $count = (int) $this->option('count'); $this->info("Sending {$count} emails..."); // Send emails logic $this->info('Emails sent!'); return Command::SUCCESS; } }Creating the command:
bash# Django: Create file manually in management/commands/ # Laravel: Use Artisan generator php artisan make:command SendEmailsThe custom command examples are available in
artisan-commands.phpanddjango-manage.py.Artisan Tinker - Interactive Shell
Laravel's
tinkeris similar to Django'sshellbut more powerful:Django Shell:
bashpython manage.py shell >>> from myapp.models import User >>> User.objects.all()Laravel Tinker:
bashphp artisan tinker >>> User::all() >>> User::where('email', 'john@example.com')->first() >>> $user = User::factory()->create()Tinker provides full access to your Laravel application, including models, facades, and helpers. It's perfect for quick database queries and testing.
Route Listing - Discover Your Routes
Laravel's
route:listcommand shows all registered routes, similar to Django's URL pattern inspection:Django (Python):
bash# Django doesn't have a built-in command, but you can: python manage.py show_urls # Requires django-extensions # Or inspect urls.py manuallyLaravel (PHP):
bash# List all routes php artisan route:list # Filter by method php artisan route:list --method=GET # Filter by name php artisan route:list --name=user # Show route details php artisan route:list --columns=method,uri,name,actionThis is invaluable for debugging routing issues and discovering available endpoints.
Cache and Config Management
Laravel provides commands to manage cache and configuration:
Laravel (PHP):
bash# Clear various caches php artisan cache:clear # Clear application cache php artisan config:clear # Clear configuration cache php artisan route:clear # Clear route cache php artisan view:clear # Clear compiled view cache # Cache for production (optimization) php artisan config:cache # Cache configuration files php artisan route:cache # Cache routes php artisan view:cache # Cache compiled viewsDjango (Python):
bash# Django's equivalent python manage.py collectstatic # Collect static files python manage.py clearsessions # Clear session data # Django doesn't cache config/routes by defaultProduction Optimization
In production, use
php artisan config:cacheandphp artisan route:cacheto improve performance. These commands compile configuration and routes into optimized files. Remember to runconfig:clearandroute:clearafter making changes in development!Development Server Options
Laravel's development server offers more options than Django's:
Django (Python):
bashpython manage.py runserver python manage.py runserver 8000 python manage.py runserver 0.0.0.0:8000Laravel (PHP):
bashphp artisan serve php artisan serve --port=8000 php artisan serve --host=0.0.0.0 php artisan serve --port=8080 --host=127.0.0.1Both serve your application locally, but Laravel's server is built on PHP's built-in server, while Django uses a development server written in Python.
Artisan's Built-in Helpers
Laravel's Artisan provides helpful output formatting:
php# In an Artisan command $this->info('Success message'); // Green text $this->error('Error message'); // Red text $this->warn('Warning message'); // Yellow text $this->line('Plain message'); // Default text $this->table(['Name', 'Email'], $data); // Formatted tableDjango's
manage.pyuses similar helpers:python# In a Django management command self.stdout.write(self.style.SUCCESS('Success message')) self.stdout.write(self.style.ERROR('Error message')) self.stdout.write(self.style.WARNING('Warning message'))
Expected Result
After completing this step, you'll understand that:
- Laravel's
php artisanis equivalent to Django'spython manage.py - Artisan provides more built-in commands than Django's
manage.py - Both frameworks allow custom command creation
- Artisan's command syntax is more consistent and discoverable
- Laravel's
tinkeris similar to Django'sshellbut more powerful - Artisan's
make:*commands generate boilerplate code automatically - Artisan provides helpful output formatting methods
Why It Works
Laravel's Artisan CLI follows the same philosophy as Django's manage.py: provide a single entry point for all framework operations. Artisan is built on Symfony Console component, which provides a robust command-line interface. The make:* commands generate boilerplate code, reducing manual file creation. Artisan's help system (php artisan help <command>) provides inline documentation, making it easy to discover command options.
Troubleshooting
- "Command not found" — Ensure you're in a Laravel project directory. Artisan commands only work within Laravel projects. Check with
php artisan list. - "Artisan command doesn't exist" — Some commands require specific packages. For example,
php artisan make:usermight require Laravel Breeze or Jetstream. Usephp artisan listto see available commands. - "Permission denied" — Ensure the
artisanfile is executable:chmod +x artisan(though this is rarely needed). - "Class not found" — After creating a custom command, run
composer dump-autoloadto refresh the autoloader. Laravel should auto-discover commands inapp/Console/Commands/.
Step 2: Database Migrations - Version Control for Your Schema (~20 min)
Goal
Master Laravel migrations and understand how they compare to Django migrations.
Actions
Creating Migrations
Both frameworks use migrations for database schema versioning:
Django (Python):
bash# Create a migration python manage.py makemigrations # Create a migration for a specific app python manage.py makemigrations myapp # Create an empty migration python manage.py makemigrations --empty myappLaravel (PHP):
bash# Create a migration php artisan make:migration create_users_table # Create a migration with specific name php artisan make:migration add_email_to_users_table --table=users # Create an empty migration php artisan make:migration custom_migrationMigration File Structure
Here's how migration files compare:
Django Migration:
python# filename: myapp/migrations/0001_initial.py from django.db import migrations, models class Migration(migrations.Migration): dependencies = [] operations = [ migrations.CreateModel( name='User', fields=[ ('id', models.BigAutoField(primary_key=True)), ('name', models.CharField(max_length=255)), ('email', models.EmailField(max_length=255, unique=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ], ), ]Laravel Migration:
php# filename: database/migrations/2024_01_01_000001_create_users_table.php <?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'); $table->string('email')->unique(); $table->timestamp('created_at')->useCurrent(); }); } public function down(): void { Schema::dropIfExists('users'); } };Laravel Migration Naming
Laravel migrations use timestamps in filenames (e.g.,
2024_01_01_120000_create_users_table.php). This ensures migrations run in chronological order, even if created on different machines. Django uses numbered migrations (0001_initial.py), which can cause conflicts in team environments.The migration examples are available in
laravel-migration.phpanddjango-migration.py.Running Migrations
Both frameworks track which migrations have been run:
Django (Python):
bash# Run all pending migrations python manage.py migrate # Run migrations for specific app python manage.py migrate myapp # Show migration status python manage.py showmigrationsLaravel (PHP):
bash# Run all pending migrations php artisan migrate # Run migrations in specific path php artisan migrate --path=database/migrations/custom # Show migration status php artisan migrate:statusRolling Back Migrations
Both frameworks support rolling back migrations:
Django (Python):
bash# Rollback last migration python manage.py migrate myapp zero python manage.py migrate myapp 0001 # Rollback to specific migration python manage.py migrate myapp 0002_previous_migrationLaravel (PHP):
bash# Rollback last batch of migrations php artisan migrate:rollback # Rollback specific number of steps php artisan migrate:rollback --step=3 # Rollback all migrations php artisan migrate:reset # Rollback and re-run migrations php artisan migrate:refreshMigration Best Practices
Both frameworks follow similar best practices:
- Never edit existing migrations — Create new migrations for changes
- Keep migrations small — One logical change per migration
- Test migrations — Always test rollbacks
- Use descriptive names —
create_users_tablenotmigration1
Expected Result
After completing this step, you'll understand that:
- Laravel migrations work similarly to Django migrations
- Both use timestamp-based versioning (Django uses numbers, Laravel uses timestamps)
- Both track which migrations have been applied
- Both support rolling back migrations
- Laravel's migration syntax is more fluent/expressive
- Laravel migrations are more explicit about up/down operations
Why It Works
Migrations provide version control for database schemas, allowing teams to share schema changes and deploy consistently. Laravel's migration system is built on top of a database abstraction layer, making it database-agnostic. The up() method defines what happens when migrating forward, while down() defines the rollback behavior. Laravel tracks migrations in a migrations table, similar to Django's django_migrations table.
Troubleshooting
- "Migration already exists" — Laravel uses timestamps in migration filenames. If you create a migration at the same second, you might get a conflict. Wait a second or manually adjust the timestamp.
- "Migration not found" — Ensure migrations are in
database/migrations/directory. Check file naming:YYYY_MM_DD_HHMMSS_description.php. - "Can't rollback" — Ensure the
down()method is properly implemented. Laravel requires bothup()anddown()methods for rollbacks to work. - "Foreign key constraint fails" — Roll back migrations in reverse order. Use
php artisan migrate:rollback --step=Nto rollback multiple migrations. - "Migration already exists" — Laravel uses timestamps in filenames. If you create migrations at the same second, you might get conflicts. Wait a second or manually adjust the timestamp.
- "Table already exists" — Use
php artisan migrate:freshto drop all tables and re-run migrations (development only!). Or checkmigrationstable to see which migrations have run.
Step 3: Testing with PHPUnit - Laravel's Testing Framework (~20 min)
Goal
Learn PHPUnit testing and understand how it compares to pytest and unittest.
Actions
Test Structure Comparison
Here's how test files compare:
pytest (Python):
python# filename: tests/test_user.py import pytest from django.contrib.auth import get_user_model User = get_user_model() def test_user_creation(): user = User.objects.create( name='John Doe', email='john@example.com' ) assert user.name == 'John Doe' assert user.email == 'john@example.com' def test_user_str(): user = User(name='John Doe', email='john@example.com') assert str(user) == 'John Doe'PHPUnit (Laravel):
php# filename: tests/Feature/UserTest.php <?php namespace Tests\Feature; use Tests\TestCase; use App\Models\User; class UserTest extends TestCase { public function test_user_creation(): void { $user = User::create([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); $this->assertEquals('John Doe', $user->name); $this->assertEquals('john@example.com', $user->email); } public function test_user_to_string(): void { $user = new User([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); $this->assertEquals('John Doe', (string) $user); } }The test examples are available in
phpunit-test.phpandpytest-test.py.Feature Tests vs Unit Tests
Laravel distinguishes between Feature tests (HTTP/Integration) and Unit tests (isolated):
Laravel Feature Test:
php# filename: tests/Feature/UserApiTest.php <?php namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Models\User; class UserApiTest extends TestCase { use RefreshDatabase; // Resets database after each test public function test_user_can_be_created_via_api(): void { $response = $this->postJson('/api/users', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password123', ]); $response->assertStatus(201) ->assertJson([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); // Assert database has the user $this->assertDatabaseHas('users', [ 'email' => 'john@example.com', ]); } }Laravel Testing Helpers
Laravel provides many testing helpers beyond PHPUnit's standard assertions. Use
$response->assertStatus(),$response->assertJson(),$this->assertDatabaseHas(), andRefreshDatabasetrait for comprehensive testing. These helpers make tests more readable and maintainable than raw PHPUnit assertions.Laravel Unit Test:
php# filename: tests/Unit/UserServiceTest.php <?php namespace Tests\Unit; use Tests\TestCase; use App\Services\UserService; class UserServiceTest extends TestCase { public function test_user_service_creates_user(): void { $service = new UserService(); $user = $service->createUser('John Doe', 'john@example.com'); $this->assertEquals('John Doe', $user->name); } }pytest equivalent:
python# Feature test (using Django test client) def test_user_can_be_created_via_api(client): response = client.post('/api/users/', { 'name': 'John Doe', 'email': 'john@example.com', }) assert response.status_code == 201 assert response.json()['name'] == 'John Doe' # Unit test (isolated) def test_user_service_creates_user(): service = UserService() user = service.create_user('John Doe', 'john@example.com') assert user.name == 'John Doe'Assertions Comparison
Both frameworks provide similar assertion methods:
pytest PHPUnit Purpose assert x == y$this->assertEquals($x, $y)Equality assertion assert x is None$this->assertNull($x)Null assertion assert x in list$this->assertContains($x, $list)Contains assertion assert response.status_code == 200$response->assertStatus(200)HTTP status assert 'text' in response.content$response->assertSee('text')Content assertion Running Tests
Both frameworks provide command-line test runners:
pytest (Python):
bash# Run all tests pytest # Run specific test file pytest tests/test_user.py # Run specific test pytest tests/test_user.py::test_user_creation # Run with coverage pytest --cov=app tests/PHPUnit (Laravel):
bash# Run all tests php artisan test # Run specific test file php artisan test tests/Feature/UserTest.php # Run specific test php artisan test --filter test_user_creation # Run with coverage php artisan test --coverageDatabase Testing
Laravel provides database testing helpers:
php# filename: tests/Feature/UserTest.php <?php namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Models\User; class UserTest extends TestCase { use RefreshDatabase; // Resets database after each test public function test_user_can_be_created(): void { $user = User::factory()->create([ 'name' => 'John Doe', ]); $this->assertDatabaseHas('users', [ 'name' => 'John Doe', ]); } }Django equivalent:
python# filename: tests/test_user.py from django.test import TestCase from django.contrib.auth import get_user_model User = get_user_model() class UserTest(TestCase): def test_user_can_be_created(self): user = User.objects.create(name='John Doe') self.assertTrue(User.objects.filter(name='John Doe').exists())
Expected Result
After completing this step, you'll understand that:
- PHPUnit is Laravel's testing framework, equivalent to pytest/unittest
- Laravel distinguishes Feature tests (HTTP) from Unit tests (isolated)
- PHPUnit assertions are method-based (
$this->assertEquals()), while pytest uses functions (assert x == y) - Laravel provides database testing helpers (
RefreshDatabase,assertDatabaseHas) - Both frameworks support test discovery and filtering
- Laravel's
php artisan testis more integrated than pytest's standalone runner
Why It Works
PHPUnit is PHP's standard testing framework, similar to pytest for Python. Laravel extends PHPUnit with additional helpers for HTTP testing, database testing, and assertions. The RefreshDatabase trait automatically resets the database between tests, similar to Django's TestCase. Laravel's test runner (php artisan test) wraps PHPUnit with Laravel-specific features like automatic environment detection and better error reporting.
Troubleshooting
- "Test database not found" — Laravel uses a separate test database. Ensure
.env.testingexists or setDB_DATABASEinphpunit.xml. Laravel will create the database automatically if it doesn't exist. - "Migration errors in tests" — Use
RefreshDatabasetrait to ensure migrations run. Check thatdatabase/migrationsdirectory contains all migrations. - "Factory not found" — Ensure factories are in
database/factories/directory and properly namespaced. Usephp artisan make:factory ModelFactoryto create factories. - "Test takes too long" — Use
RefreshDatabaseinstead ofDatabaseMigrationsfor faster tests. Consider using in-memory SQLite for unit tests.
Step 4: Seeders and Factories - Test Data Management (~15 min)
Goal
Understand Laravel seeders and factories, and how they compare to Django fixtures and Faker.
Actions
Creating Seeders
Seeders populate the database with initial or test data:
Django Fixture:
json# filename: myapp/fixtures/users.json [ { "model": "auth.user", "pk": 1, "fields": { "name": "John Doe", "email": "john@example.com" } } ]Laravel Seeder:
php# filename: database/seeders/UserSeeder.php <?php namespace Database\Seeders; use Illuminate\Database\Seeder; use App\Models\User; class UserSeeder extends Seeder { public function run(): void { User::create([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); } }Creating seeders:
bash# Django: Create JSON file manually # Laravel: Use Artisan generator php artisan make:seeder UserSeederThe seeder examples are available in
laravel-seeder.phpanddjango-fixture.json.Running Seeders
Both frameworks provide ways to load seed data:
Django (Python):
bash# Load fixture python manage.py loaddata users.json # Load all fixtures python manage.py loaddata *Laravel (PHP):
bash# Run specific seeder php artisan db:seed --class=UserSeeder # Run all seeders php artisan db:seed # Migrate and seed php artisan migrate --seedUsing Factories for Test Data
Factories generate test data programmatically:
Django Factory (using factory_boy):
python# filename: myapp/factories.py import factory from django.contrib.auth import get_user_model User = get_user_model() class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User name = factory.Faker('name') email = factory.Faker('email')Laravel Factory:
php# filename: database/factories/UserFactory.php <?php namespace Database\Factories; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; class UserFactory extends Factory { protected $model = User::class; public function definition(): array { return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => bcrypt('password'), // Default password 'remember_token' => Str::random(10), ]; } /** * Indicate that the user's email should be unverified. */ public function unverified(): static { return $this->state(fn (array $attributes) => [ 'email_verified_at' => null, ]); } }Factory States
Laravel factories support "states" that modify the default attributes. Use
User::factory()->unverified()->create()to create an unverified user. This is more flexible than Django's factory_boy, which requires separate factory classes for variations.Using factories in tests:
Django:
python# In a test user = UserFactory.create(name='John Doe') users = UserFactory.create_batch(10)Laravel:
php// In a test $user = User::factory()->create(['name' => 'John Doe']); $users = User::factory()->count(10)->create();The factory examples are available in
laravel-factory.php.DatabaseSeeder - Main Seeder
Laravel's
DatabaseSeederis the main entry point for seeding:php# filename: database/seeders/DatabaseSeeder.php <?php namespace Database\Seeders; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { public function run(): void { $this->call([ UserSeeder::class, PostSeeder::class, ]); } }Django equivalent (using management command):
python# filename: management/commands/seed_data.py from django.core.management.base import BaseCommand from myapp.factories import UserFactory, PostFactory class Command(BaseCommand): def handle(self, *args, **options): UserFactory.create_batch(10) PostFactory.create_batch(20)
Expected Result
After completing this step, you'll understand that:
- Laravel seeders are programmatic (PHP code), while Django fixtures are data files (JSON/YAML)
- Laravel factories are built-in, while Django uses third-party
factory_boy - Both frameworks support generating test data programmatically
- Laravel's factory syntax is more fluent (
User::factory()->create()) - Seeders run via
php artisan db:seed, similar to Django'sloaddatacommand - Laravel's
DatabaseSeedercoordinates multiple seeders
Why It Works
Seeders provide a way to populate databases with initial or test data. Laravel's seeders are PHP classes, making them more flexible than JSON fixtures. Factories generate test data programmatically using Faker, similar to Python's factory_boy. Laravel's factories are integrated into the framework, while Django requires factory_boy as a third-party package. The DatabaseSeeder class acts as the main entry point, allowing you to coordinate multiple seeders.
Troubleshooting
- "Seeder class not found" — Ensure seeders are in
database/seeders/directory and properly namespaced. Runcomposer dump-autoloadto refresh autoloader. - "Factory not found" — Ensure factories are in
database/factories/directory. Usephp artisan make:factory UserFactoryto create factories. Runcomposer dump-autoloadafter creating factories. - "Duplicate entry error" — Use
fake()->unique()for unique fields, or clear database before seeding:php artisan migrate:fresh --seed. - "Faker not working" — Laravel includes Faker by default. If you see errors, ensure you're using
fake()helper or$this->fakerin factories. In Laravel 11+, usefake()instead of$this->faker.
Step 5: Laravel Conventions - "Conventions Over Configuration" (~10 min)
Goal
Understand Laravel's naming conventions and how they compare to Django/Flask conventions.
Actions
File Naming Conventions
Laravel follows strict naming conventions:
Laravel Django Flask Example User.php(Model)models.py(containsUser)models.py(containsUser)Model class UserController.phpviews.py(containsUserView)views.py(function-based)Controller/View 2024_01_01_create_users.php0001_initial.pyN/A (no migrations) Migration UserSeeder.phpfixtures/users.jsonN/A Seeder/Fixture UserFactory.phpfactories.py(factory_boy)N/A Factory Directory Structure Comparison
Laravel Structure:
app/ ├── Models/ │ └── User.php ├── Http/ │ ├── Controllers/ │ │ └── UserController.php │ └── Middleware/ ├── Console/ │ └── Commands/ database/ ├── migrations/ ├── seeders/ └── factories/ tests/ ├── Feature/ └── Unit/Django Structure:
myapp/ ├── models.py ├── views.py ├── urls.py ├── admin.py migrations/ ├── 0001_initial.py tests/ ├── test_models.py fixtures/ └── users.jsonNaming Patterns
Laravel uses specific naming patterns:
Models:
php// Laravel: Singular, PascalCase class User extends Model {} // Table: 'users' (plural, snake_case) // Django: Singular, PascalCase class User(models.Model): class Meta: db_table = 'users' # Optional, defaults to 'myapp_user'Controllers:
php// Laravel: PascalCase with 'Controller' suffix class UserController extends Controller {} // Django: Function-based or class-based def user_list(request): pass # or class UserListView(ListView): passMigrations:
php// Laravel: Timestamp_description.php // 2024_01_01_120000_create_users_table.php // Django: Numbered_description.py // 0001_initial.pyConvention Benefits
Laravel's conventions reduce configuration:
Laravel (Convention-based):
php// Model automatically knows table name is 'users' class User extends Model {} // Route automatically maps to UserController@index Route::get('/users', [UserController::class, 'index']);Django (More explicit):
python# Model explicitly defines table name (optional) class User(models.Model): class Meta: db_table = 'users' # URL explicitly maps to view path('users/', views.user_list, name='user_list')
Expected Result
After completing this step, you'll understand that:
- Laravel follows strict naming conventions (singular models, plural tables)
- Laravel's directory structure is more organized (separate folders for Models, Controllers)
- Django uses file-based organization (
models.py,views.py) - Laravel's conventions reduce boilerplate configuration
- Both frameworks follow "conventions over configuration" philosophy
- Laravel's conventions are more opinionated than Django's
Why It Works
Laravel's conventions reduce cognitive load by providing predictable patterns. If you know a model is User, you know the table is users, the controller is UserController, and the migration follows a timestamp pattern. This reduces configuration and makes code more discoverable. Django also follows conventions but is less opinionated, allowing more flexibility at the cost of more configuration.
Troubleshooting
- "Model not found" — Ensure models are in
app/Models/directory and properly namespaced (App\Models\User). Usecomposer dump-autoloadto refresh autoloader. - "Table not found" — Laravel automatically pluralizes model names. If your table name differs, override
$tableproperty:protected $table = 'custom_table_name'; - "Controller not found" — Ensure controllers are in
app/Http/Controllers/directory. Usephp artisan make:controller UserControllerto create controllers. Runcomposer dump-autoloadafter creating controllers. - "Route not found" — Check route naming. Laravel uses
Route::get('/users', [UserController::class, 'index'])syntax. Ensure controller method exists. Usephp artisan route:listto see all registered routes. - "Table name doesn't match" — Laravel automatically pluralizes model names. If your table name differs, override
$tableproperty:protected $table = 'custom_table_name';
Step 6: Code Generation - Laravel's make:* Commands (~10 min)
Goal
Understand Laravel's code generation commands and how they compare to Django's manual file creation approach.
Actions
Code Generation Overview
Laravel's
make:*commands generate boilerplate code automatically, saving significant time. Django doesn't have equivalent generators—you create files manually:Django (Python) - Manual Creation:
bash# Django: Create files manually # 1. Create models.py file # 2. Create views.py file # 3. Create urls.py file # 4. Create forms.py file (if needed) # 5. Write all boilerplate code yourselfLaravel (PHP) - Code Generation:
bash# Laravel: Generate files with Artisan php artisan make:model Post php artisan make:controller PostController php artisan make:request StorePostRequest php artisan make:middleware CheckAgeModel Generation
Laravel can generate models with migrations and factories:
Laravel:
bash# Generate model only php artisan make:model Post # Generate model with migration php artisan make:model Post -m # Generate model with migration and factory php artisan make:model Post -m -f # Generate model with migration, factory, seeder, and controller php artisan make:model Post -aDjango equivalent:
bash# Django: Create model manually in models.py # Then create migration python manage.py makemigrationsThe generated Laravel model includes proper namespacing, type hints, and follows conventions:
php# Generated: app/Models/Post.php <?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Post extends Model { // Model code here }Controller Generation
Laravel can generate controllers with CRUD methods:
Laravel:
bash# Generate basic controller php artisan make:controller PostController # Generate resource controller (CRUD methods) php artisan make:controller PostController --resource # Generate API resource controller (no create/edit views) php artisan make:controller PostController --apiDjango equivalent:
python# Django: Create views.py manually # Or use class-based views from django.views.generic import ListView, DetailView class PostListView(ListView): model = PostThe
--resourceflag generates all CRUD methods:index(),create(),store(),show(),edit(),update(),destroy().Form Request Validation
Laravel generates form request classes for validation:
Laravel:
bashphp artisan make:request StorePostRequestGenerated file:
php# app/Http/Requests/StorePostRequest.php <?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StorePostRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'title' => 'required|string|max:255', 'content' => 'required|string', ]; } }Django equivalent:
python# Django: Create forms.py manually from django import forms class PostForm(forms.ModelForm): class Meta: model = Post fields = ['title', 'content']Middleware Generation
Laravel generates middleware classes:
Laravel:
bashphp artisan make:middleware CheckAgeGenerated file:
php# app/Http/Middleware/CheckAge.php <?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; class CheckAge { public function handle(Request $request, Closure $next): mixed { // Middleware logic here return $next($request); } }Django equivalent:
python# Django: Create middleware manually class CheckAgeMiddleware: def __init__(self, get_response): self.get_response = get_responseAPI Resource Generation
Laravel can generate API resources for transforming model data:
Laravel:
bashphp artisan make:resource PostResourceThis generates a resource class for formatting API responses, something Django REST Framework handles differently.
Expected Result
After completing this step, you'll understand that:
- Laravel's
make:*commands generate boilerplate code automatically - Django requires manual file creation—no equivalent generators
- Laravel's code generation follows conventions, reducing errors
- Flags like
-m,-f,--resourcecreate related files together - Code generation is a major productivity advantage in Laravel
Why It Works
Laravel's code generation commands follow the framework's conventions, ensuring consistency across projects. The make:* commands create properly namespaced, type-hinted code that follows PSR standards. This reduces boilerplate writing and prevents common mistakes. Django's approach requires more manual work but gives you complete control over file structure.
Troubleshooting
- "Command not found" — Ensure you're using Laravel 11.x. Some
make:*commands may vary by version. Usephp artisan listto see available commands. - "Generated file has wrong namespace" — Laravel auto-detects namespaces based on directory structure. Ensure you're running commands from the project root.
- "Can't find generated file" — Generated files go to standard Laravel directories:
app/Models/,app/Http/Controllers/, etc. Check these directories after generation.
Exercises
Test your understanding of Laravel's developer experience tools by completing these exercises:
Exercise 1: Create a Custom Artisan Command (~15 min)
Goal: Practice creating custom Artisan commands and compare to Django management commands.
Requirements:
- Create a custom Artisan command called
users:listthat displays all users in a table format - The command should accept an optional
--limitargument (default: 10) - Display users in a formatted table with columns: ID, Name, Email, Created At
- Compare your implementation to a Django management command that does the same
Validation: Test your implementation:
# Run the command
php artisan users:list
# Run with limit
php artisan users:list --limit=5
# Expected: Formatted table with user dataReference: See artisan-commands.php for examples.
Exercise 2: Write a Migration and Seeder (~20 min)
Goal: Practice creating migrations and seeders, comparing to Django migrations and fixtures.
Requirements:
Create a migration for a
poststable with fields:id(primary key)title(string, required)content(text)user_id(foreign key to users table)created_atandupdated_at(timestamps)
Create a
PostSeederthat creates 10 posts with fake dataUpdate
DatabaseSeederto callPostSeederRun the migration and seeder
Compare your implementation to Django migrations and fixtures
Validation: Verify your implementation:
# Run migration
php artisan migrate
# Run seeder
php artisan db:seed --class=PostSeeder
# Check database
php artisan tinker
>>> Post::count() // Should return 10Reference: See laravel-migration.php and laravel-seeder.php for examples.
Exercise 3: Write a PHPUnit Test (~15 min)
Goal: Practice writing PHPUnit tests and compare to pytest tests.
Requirements:
Create a
PostTestfeature test that:- Tests creating a post via API
- Tests retrieving a post
- Tests updating a post
- Tests deleting a post
Use factories to create test data
Use
RefreshDatabasetrait for database isolationCompare your implementation to pytest tests
Validation: Run your tests:
# Run all tests
php artisan test
# Run specific test
php artisan test --filter PostTest
# Expected: All tests passReference: See phpunit-test.php for examples.
Exercise 4: Use Code Generation Commands (~10 min)
Goal: Practice using Laravel's make:* commands to generate boilerplate code.
Requirements:
Generate a
Postmodel with migration and factory:bashphp artisan make:model Post -m -fGenerate a resource controller for posts:
bashphp artisan make:controller PostController --resourceGenerate a form request for storing posts:
bashphp artisan make:request StorePostRequestInspect the generated files and compare to Django's manual file creation approach
Validation: Verify the generated files:
# Check model was created
ls app/Models/Post.php
# Check migration was created
ls database/migrations/*_create_posts_table.php
# Check controller was created
ls app/Http/Controllers/PostController.php
# Check request was created
ls app/Http/Requests/StorePostRequest.phpReference: See code generation examples in artisan-commands.php.
Wrap-up
Congratulations! You've completed the Laravel developer experience chapter. You now understand:
- ✓ Artisan CLI commands and how they compare to Django's
manage.py - ✓ Laravel migrations vs Django migrations workflow
- ✓ PHPUnit testing vs pytest workflows
- ✓ Laravel seeders and factories vs Django fixtures and factory_boy
- ✓ Laravel conventions vs Django/Flask conventions
- ✓ Laravel's "conventions over configuration" philosophy
- ✓ How to create custom Artisan commands, migrations, seeders, and tests
- ✓ Laravel's code generation commands (
make:*) and their productivity benefits - ✓ Cache and configuration management with Artisan commands
- ✓ Route listing and development server options
What You've Achieved
You've built a comprehensive understanding of Laravel's developer experience tools. You can see that Laravel's tools mirror Python's best practices while adding their own delightful touches. Artisan CLI provides more built-in commands than Django's manage.py. Laravel's code generation commands (make:*) are a major productivity advantage—Django has no equivalent generators, requiring manual file creation. Laravel migrations work similarly to Django migrations but with more expressive syntax. PHPUnit testing is equivalent to pytest, with Laravel-specific helpers for HTTP and database testing. Seeders and factories provide flexible test data management, and Laravel's conventions reduce configuration overhead. Cache and configuration management commands help optimize applications for production.
Next Steps
In Chapter 04, we'll explore PHP syntax and language differences for Python developers. You'll learn:
- Variable prefixes (
$) and how they compare to Python - OOP differences between PHP and Python
- Type declarations and how they compare to Python type hints
- Namespaces vs Python modules
- Side-by-side syntax comparisons
Your understanding of Laravel's developer experience tools will help you appreciate PHP's syntax choices and how they contribute to Laravel's developer-friendly features. The conventions you've learned here (naming patterns, directory structure) will make PHP syntax feel more natural.
Code Examples
All code examples from this chapter are available in the code/chapter-03/ directory:
- Artisan Commands:
artisan-commands.php— Custom Artisan command examples - Django manage.py:
django-manage.py— Django management command comparison - Laravel Migration:
laravel-migration.php— Laravel migration example - Django Migration:
django-migration.py— Django migration comparison - PHPUnit Test:
phpunit-test.php— PHPUnit test example - pytest Test:
pytest-test.py— pytest comparison example - Laravel Seeder:
laravel-seeder.php— Laravel seeder example - Django Fixture:
django-fixture.json— Django fixture comparison - Laravel Factory:
laravel-factory.php— Laravel factory example
See the README.md for detailed instructions on running each example.
Further Reading
To deepen your understanding:
- Laravel Artisan Documentation — Complete Artisan CLI reference
- Laravel Migrations Guide — Database migrations documentation
- PHPUnit Documentation — PHPUnit testing framework
- Laravel Testing Guide — Laravel-specific testing features
- Laravel Seeders Documentation — Database seeding guide
- Laravel Factories Documentation — Model factories guide
- Django Management Commands — Django management commands reference
- pytest Documentation — pytest testing framework
Ready to Explore PHP Syntax?
Head to Chapter 04: The PHP Syntax & Language Differences for Python Devs to learn about PHP syntax and how it compares to Python!