Laravel MVC Architecture: A Beginner's Guide with Simple Examples
Learn the Model-View-Controller pattern and how Laravel implements it.
PHPUnit is the industry-standard testing framework for PHP, and Laravel provides first-class support for it. Understanding how to effectively write both unit and feature tests is crucial for building robust, maintainable applications.
<?xml version="1.0" encoding="UTF-8"?>
<!-- phpunit.xml -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
<testsuite name="Integration">
<directory suffix="Test.php">./tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./app</directory>
<directory suffix=".php">./src</directory>
</include>
<exclude>
<directory>./app/Console</directory>
<directory>./app/Exceptions</directory>
<directory>./app/Http/Middleware</directory>
</exclude>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="LOG_CHANNEL" value="null"/>
<env name="FILESYSTEM_DISK" value="local"/>
<ini name="memory_limit" value="512M"/>
<ini name="error_reporting" value="-1"/>
</php>
<coverage>
<report>
<html outputDirectory="coverage/html" lowUpperBound="50" highLowerBound="90"/>
<clover outputFile="coverage/clover.xml"/>
<text outputFile="coverage/coverage.txt" showOnlySummary="true"/>
</report>
</coverage>
</phpunit>
<?php
// tests/Unit/AssertionExamplesTest.php
namespace Tests\Unit;
use Tests\TestCase;
class AssertionExamplesTest extends TestCase
{
public function test_basic_assertions()
{
// Equality assertions
$this->assertEquals('expected', 'expected');
$this->assertSame(123, 123); // Strict comparison
$this->assertNotEquals('foo', 'bar');
// Boolean assertions
$this->assertTrue(true);
$this->assertFalse(false);
$this->assertNull(null);
$this->assertNotNull('value');
// Type assertions
$this->assertIsArray([]);
$this->assertIsBool(true);
$this->assertIsFloat(3.14);
$this->assertIsInt(42);
$this->assertIsNumeric('123');
$this->assertIsObject(new \stdClass());
$this->assertIsString('string');
// Comparison assertions
$this->assertGreaterThan(1, 2);
$this->assertGreaterThanOrEqual(2, 2);
$this->assertLessThan(2, 1);
$this->assertLessThanOrEqual(1, 1);
// String assertions
$this->assertStringContainsString('foo', 'foobar');
$this->assertStringStartsWith('foo', 'foobar');
$this->assertStringEndsWith('bar', 'foobar');
$this->assertMatchesRegularExpression('/foo/', 'foobar');
// Array assertions
$array = ['foo' => 'bar'];
$this->assertArrayHasKey('foo', $array);
$this->assertArrayNotHasKey('baz', $array);
$this->assertContains('bar', $array);
$this->assertNotContains('baz', $array);
$this->assertCount(1, $array);
}
public function test_laravel_specific_assertions()
{
// HTTP assertions (for feature tests)
$response = response()->json(['data' => 'value']);
// Status assertions
$this->assertEquals(200, $response->status());
// JSON assertions
$this->assertJson(['data' => 'value'], $response->content());
// Database assertions
$user = \App\Models\User::factory()->create();
$this->assertDatabaseHas('users', ['id' => $user->id]);
$this->assertDatabaseMissing('users', ['email' => 'nonexistent@example.com']);
$this->assertDatabaseCount('users', 1);
// Authentication assertions
$this->actingAs($user);
$this->assertAuthenticated();
$this->assertAuthenticatedAs($user);
}
}
<?php
// app/Services/OrderService.php
namespace App\Services;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class OrderService
{
public function createOrder(User $user, array $items, array $shippingInfo): Order
{
return DB::transaction(function () use ($user, $items, $shippingInfo) {
// Validate items
$this->validateItems($items);
// Calculate totals
$subtotal = $this->calculateSubtotal($items);
$shipping = $this->calculateShipping($subtotal, $shippingInfo);
$tax = $this->calculateTax($subtotal, $shippingInfo);
$total = $subtotal + $shipping + $tax;
// Check stock availability
$this->checkStockAvailability($items);
// Create order
$order = Order::create([
'user_id' => $user->id,
'order_number' => $this->generateOrderNumber(),
'subtotal' => $subtotal,
'shipping' => $shipping,
'tax' => $tax,
'total' => $total,
'status' => 'pending',
'shipping_address' => $shippingInfo,
]);
// Add order items
foreach ($items as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'total_price' => $item['quantity'] * $item['unit_price'],
]);
// Update stock
Product::where('id', $item['product_id'])
->decrement('stock_quantity', $item['quantity']);
}
// Log order creation
Log::info('Order created', ['order_id' => $order->id, 'user_id' => $user->id]);
return $order->load('items');
});
}
protected function validateItems(array $items): void
{
if (empty($items)) {
throw new \InvalidArgumentException('Order must contain at least one item');
}
foreach ($items as $item) {
if (!isset($item['product_id'], $item['quantity'], $item['unit_price'])) {
throw new \InvalidArgumentException('Each item must have product_id, quantity, and unit_price');
}
if ($item['quantity'] <= 0) {
throw new \InvalidArgumentException('Quantity must be greater than 0');
}
if ($item['unit_price'] < 0) {
throw new \InvalidArgumentException('Unit price cannot be negative');
}
}
}
protected function calculateSubtotal(array $items): float
{
return array_reduce($items, function ($carry, $item) {
return $carry + ($item['quantity'] * $item['unit_price']);
}, 0);
}
protected function calculateShipping(float $subtotal, array $shippingInfo): float
{
// Simplified shipping calculation
if ($subtotal > 100) {
return 0; // Free shipping for orders over $100
}
return 10.00; // Flat rate shipping
}
protected function calculateTax(float $subtotal, array $shippingInfo): float
{
// Simplified tax calculation (8% tax rate)
return $subtotal * 0.08;
}
}
<?php
// tests/Unit/Services/OrderServiceTest.php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\OrderService;
use App\Models\User;
use App\Models\Product;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
class OrderServiceTest extends TestCase
{
use RefreshDatabase;
private OrderService $orderService;
protected function setUp(): void
{
parent::setUp();
$this->orderService = new OrderService();
}
public function test_create_order_successfully()
{
// Arrange
$user = User::factory()->create();
$product = Product::factory()->create([
'price' => 50.00,
'stock_quantity' => 10,
]);
$items = [
[
'product_id' => $product->id,
'quantity' => 2,
'unit_price' => 50.00,
],
];
$shippingInfo = [
'address' => '123 Main St',
'city' => 'New York',
'country' => 'USA',
];
// Act
$order = $this->orderService->createOrder($user, $items, $shippingInfo);
// Assert
$this->assertInstanceOf(Order::class, $order);
$this->assertEquals($user->id, $order->user_id);
$this->assertEquals('pending', $order->status);
$this->assertEquals(100.00, $order->subtotal); // 2 * 50
$this->assertEquals(10.00, $order->shipping); // Flat rate
$this->assertEquals(8.00, $order->tax); // 8% of 100
$this->assertEquals(118.00, $order->total); // 100 + 10 + 8
// Check order items
$this->assertCount(1, $order->items);
$this->assertEquals($product->id, $order->items[0]->product_id);
$this->assertEquals(2, $order->items[0]->quantity);
$this->assertEquals(100.00, $order->items[0]->total_price);
// Check stock was updated
$this->assertEquals(8, $product->fresh()->stock_quantity); // 10 - 2
// Check database
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'user_id' => $user->id,
'total' => 118.00,
]);
}
public function test_create_order_with_empty_items_throws_exception()
{
$user = User::factory()->create();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Order must contain at least one item');
$this->orderService->createOrder($user, [], []);
}
public function test_create_order_with_invalid_item_structure_throws_exception()
{
$user = User::factory()->create();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Each item must have product_id, quantity, and unit_price');
$this->orderService->createOrder($user, [
['product_id' => 1] // Missing quantity and unit_price
], []);
}
public function test_create_order_with_zero_quantity_throws_exception()
{
$user = User::factory()->create();
$product = Product::factory()->create();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Quantity must be greater than 0');
$this->orderService->createOrder($user, [
['product_id' => $product->id, 'quantity' => 0, 'unit_price' => 10.00]
], []);
}
/**
* @dataProvider shippingCalculationProvider
*/
public function test_shipping_calculation($subtotal, $expectedShipping)
{
$method = new \ReflectionMethod($this->orderService, 'calculateShipping');
$method->setAccessible(true);
$result = $method->invoke($this->orderService, $subtotal, []);
$this->assertEquals($expectedShipping, $result);
}
public function shippingCalculationProvider(): array
{
return [
'free shipping for large order' => [150.00, 0.00],
'flat rate for small order' => [50.00, 10.00],
'exactly 100 should get flat rate' => [100.00, 10.00],
];
}
}
<?php
// tests/Feature/Api/ProductApiTest.php
namespace Tests\Feature\Api;
use Tests\TestCase;
use App\Models\User;
use App\Models\Product;
use App\Models\Category;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class ProductApiTest extends TestCase
{
use RefreshDatabase;
public function test_get_products_list()
{
Product::factory()->count(15)->create();
$response = $this->getJson('/api/products');
$response->assertStatus(200);
$response->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'price', 'slug', 'description']
],
'links',
'meta',
]);
$response->assertJsonCount(15, 'data');
}
public function test_get_single_product()
{
$product = Product::factory()->create();
$response = $this->getJson("/api/products/{$product->id}");
$response->assertStatus(200);
$response->assertJson([
'data' => [
'id' => $product->id,
'name' => $product->name,
'price' => $product->price,
'description' => $product->description,
]
]);
}
public function test_get_nonexistent_product_returns_404()
{
$response = $this->getJson('/api/products/999');
$response->assertStatus(404);
$response->assertJson(['message' => 'Product not found']);
}
public function test_create_product_as_admin()
{
$admin = User::factory()->create(['role' => 'admin']);
$category = Category::factory()->create();
Storage::fake('public');
$image = UploadedFile::fake()->image('product.jpg');
$productData = [
'name' => 'New Product',
'description' => 'Product description',
'price' => 99.99,
'stock_quantity' => 10,
'category_id' => $category->id,
'images' => [$image],
];
$response = $this->actingAs($admin)
->postJson('/api/products', $productData);
$response->assertStatus(201);
$response->assertJson([
'message' => 'Product created successfully',
'data' => [
'name' => 'New Product',
'price' => 99.99,
]
]);
$this->assertDatabaseHas('products', [
'name' => 'New Product',
'price' => 99.99,
]);
// Check image was stored
Storage::disk('public')->assertExists('products/' . $image->hashName());
}
public function test_create_product_requires_authentication()
{
$response = $this->postJson('/api/products', []);
$response->assertStatus(401);
}
public function test_create_product_requires_admin_role()
{
$user = User::factory()->create(['role' => 'customer']);
$response = $this->actingAs($user)
->postJson('/api/products', []);
$response->assertStatus(403);
}
public function test_create_product_validates_required_fields()
{
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->postJson('/api/products', []);
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'name', 'price', 'category_id'
]);
}
}
<?php
// app/Services/PaymentProcessor.php
namespace App\Services;
interface PaymentGateway
{
public function charge(float $amount, array $details): array;
public function refund(string $transactionId, float $amount = null): array;
}
class PaymentProcessor
{
private PaymentGateway $gateway;
public function __construct(PaymentGateway $gateway)
{
$this->gateway = $gateway;
}
public function processPayment(float $amount, array $paymentDetails): array
{
try {
$result = $this->gateway->charge($amount, $paymentDetails);
if (!$result['success']) {
throw new \Exception('Payment failed: ' . ($result['error'] ?? 'Unknown error'));
}
return [
'success' => true,
'transaction_id' => $result['transaction_id'],
'amount' => $amount,
'processed_at' => now(),
];
} catch (\Exception $e) {
\Log::error('Payment processing failed', [
'amount' => $amount,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
}
<?php
// tests/Unit/Services/PaymentProcessorTest.php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\PaymentProcessor;
use App\Services\PaymentGateway;
use Mockery;
use Mockery\MockInterface;
class PaymentProcessorTest extends TestCase
{
private PaymentGateway|MockInterface $gatewayMock;
private PaymentProcessor $processor;
protected function setUp(): void
{
parent::setUp();
$this->gatewayMock = Mockery::mock(PaymentGateway::class);
$this->processor = new PaymentProcessor($this->gatewayMock);
}
public function test_successful_payment_processing()
{
// Arrange
$amount = 100.00;
$paymentDetails = ['card_number' => '4242424242424242'];
$transactionId = 'ch_123456';
$this->gatewayMock
->shouldReceive('charge')
->once()
->with($amount, $paymentDetails)
->andReturn([
'success' => true,
'transaction_id' => $transactionId,
]);
// Act
$result = $this->processor->processPayment($amount, $paymentDetails);
// Assert
$this->assertTrue($result['success']);
$this->assertEquals($transactionId, $result['transaction_id']);
$this->assertEquals($amount, $result['amount']);
$this->assertArrayHasKey('processed_at', $result);
}
public function test_failed_payment_processing()
{
$this->gatewayMock
->shouldReceive('charge')
->once()
->with(100.00, Mockery::any())
->andReturn([
'success' => false,
'error' => 'Insufficient funds',
]);
$result = $this->processor->processPayment(100.00, []);
$this->assertFalse($result['success']);
$this->assertEquals('Payment failed: Insufficient funds', $result['error']);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}
tests/
├── Unit/
│ ├── Models/
│ │ ├── UserTest.php
│ │ ├── ProductTest.php
│ │ └── OrderTest.php
│ ├── Services/
│ │ ├── PaymentServiceTest.php
│ │ ├── OrderServiceTest.php
│ │ └── NotificationServiceTest.php
│ ├── Repositories/
│ │ ├── UserRepositoryTest.php
│ │ └── ProductRepositoryTest.php
│
├── Feature/
│ ├── Api/
│ │ ├── AuthTest.php
│ │ ├── ProductApiTest.php
│ │ ├── OrderApiTest.php
│ │ └── UserApiTest.php
│ ├── Web/
│ │ ├── HomePageTest.php
│ │ ├── CheckoutTest.php
│ │ └── AdminTest.php
│
├── TestCase.php
├── CreatesApplication.php
└── Pest.php
# Run all tests
php artisan test
# Run specific test suite
php artisan test --testsuite=Unit
php artisan test --testsuite=Feature
# Run specific test file
php artisan test tests/Feature/ProductApiTest.php
# Run specific test method
php artisan test --filter=test_create_product
# Run tests with coverage
php artisan test --coverage
php artisan test --coverage-html=coverage/
# Run tests in parallel
php artisan test --parallel
php artisan test --parallel --processes=4
# Run tests with verbose output
php artisan test --verbose
# Stop on first failure
php artisan test --stop-on-failure
<?php
// tests/Feature/EmailNotificationTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\Order;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Foundation\Testing\RefreshDatabase;
class EmailNotificationTest extends TestCase
{
use RefreshDatabase;
public function test_order_confirmation_email()
{
Mail::fake();
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
// Trigger order confirmation email
event(new \App\Events\OrderPlaced($order));
// Assert email was sent
Mail::assertSent(\App\Mail\OrderConfirmation::class, function ($mail) use ($user, $order) {
return $mail->hasTo($user->email) &&
$mail->order->id === $order->id;
});
}
public function test_password_reset_notification()
{
Notification::fake();
$user = User::factory()->create();
// Send password reset notification
$user->sendPasswordResetNotification('token123');
Notification::assertSentTo(
$user,
\Illuminate\Auth\Notifications\ResetPassword::class,
function ($notification, $channels) use ($user) {
return in_array('mail', $channels) &&
$notification->user->id === $user->id;
}
);
}
}
<?php
// tests/Feature/FileUploadTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Foundation\Testing\RefreshDatabase;
class FileUploadTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_upload_avatar()
{
Storage::fake('avatars');
$user = User::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->image('avatar.jpg', 100, 100);
$response = $this->postJson('/api/user/avatar', [
'avatar' => $file,
]);
$response->assertStatus(200);
// Assert the file was stored
Storage::disk('avatars')->assertExists($file->hashName());
// Assert a file does not exist
Storage::disk('avatars')->assertMissing('missing.jpg');
}
public function test_avatar_must_be_valid_image()
{
Storage::fake('avatars');
$user = User::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->create('document.pdf', 1000);
$response = $this->postJson('/api/user/avatar', [
'avatar' => $file,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['avatar']);
}
}
// Bad: Testing implementation details
public function test_internal_implementation()
{
// Don't test private methods directly
$result = $this->callPrivateMethod($object, 'privateMethod');
}
// Good: Test public behavior
public function test_public_behavior()
{
$result = $object->publicMethod();
$this->assertEquals('expected', $result);
}
// Bad: Overly complex test setup
public function test_something_complex()
{
// 50 lines of setup...
// Actual test...
}
// Good: Extract setup to helper methods
public function test_something_simple()
{
$this->setupTestData();
$result = $this->service->process();
$this->assertExpected($result);
}
Remember: Testing is a skill that improves with practice. Start small, be consistent, and gradually build your testing expertise. The time invested in testing pays dividends in code quality, maintainability, and developer confidence.