Testing & DevOps
PHPUnit и Pest, unit- и feature-тесты, моки и фейки, TDD, пирамида тестирования. Docker и Compose, Git advanced, CI/CD на GitHub Actions, Linux, Nginx, деплой Laravel и мониторинг.
PHPUnit is Laravel's default testing framework. It comes pre-configured with Laravel.
composer require --dev phpunit/phpunit
php artisan make:test UserTest --unit
php artisan make:test UserControllerTest
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase
{
public function test_addition()
{
$result = 2 + 2;
$this->assertEquals(4, $result);
}
}
Assertions (утверждения) — методы класса PHPUnit\Framework\TestCase, доступные в любом тестовом классе Laravel (Laravel-ный Tests\TestCase наследует его). Каждый метод проверяет, соответствует ли фактическое состояние ожидаемому. Не совпало — тест валится с диагностическим сообщением.
Почему через $this->: потому что тестовый класс наследует TestCase, где эти методы объявлены. Без них тесты не имеют смысла — код прогонится, но никто не проверит результат.
| Assertion | Purpose | Example |
|---|---|---|
assertEquals($expected, $actual) |
Значения равны (loose ==) |
$this->assertEquals(5, 2+3); |
assertSame($expected, $actual) |
Идентичны (strict ===, тип тоже) |
$this->assertSame(5, 2+3); — '5' не пройдёт |
assertNotEquals() / assertNotSame() |
Отрицания | $this->assertNotEquals(0, $count); |
assertTrue($condition) |
Выражение истинно | $this->assertTrue($user->isAdmin()); |
assertFalse($condition) |
Выражение ложно | $this->assertFalse($user->isDeleted()); |
assertNull($value) |
Значение равно null |
$this->assertNull($result); |
assertNotNull($value) |
Значение не null |
$this->assertNotNull($user); |
assertInstanceOf($class, $object) |
Объект — экземпляр класса (или наследника) | $this->assertInstanceOf(Order::class, $order); |
assertCount($n, $collection) |
Массив/коллекция содержит N элементов | $this->assertCount(3, $users); |
assertEmpty() / assertNotEmpty() |
Пусто / не пусто | $this->assertEmpty($errors); |
assertContains($needle, $haystack) |
Значение есть в массиве/коллекции | $this->assertContains('admin', $roles); |
assertArrayHasKey($key, $array) |
Массив содержит ключ | $this->assertArrayHasKey('email', $data); |
assertGreaterThan() / assertLessThan() |
Больше / меньше | $this->assertGreaterThan(0, $total); |
assertMatchesRegularExpression($regex, $string) |
Строка матчит regex | $this->assertMatchesRegularExpression('/^ORD-\d+$/', $order->number); |
assertStringContainsString($needle, $haystack) |
Подстрока в строке | $this->assertStringContainsString('error', $log); |
assertDatabaseHas($table, $data) (Laravel) |
В таблице БД есть запись с этими полями | $this->assertDatabaseHas('orders', ['user_id' => 1]); |
assertDatabaseMissing($table, $data) (Laravel) |
В таблице нет записи с этими полями | $this->assertDatabaseMissing('users', ['email' => 'x@y']); |
assertJson($response) (Laravel) |
Ответ содержит JSON | $response->assertJson(['status' => 'ok']); |
assertJsonStructure($structure) (Laravel) |
JSON имеет ожидаемую структуру ключей | $response->assertJsonStructure(['data' => ['id', 'total']]); |
test_order_can_be_createdТипичный feature-тест, показывающий как assertion-методы работают вместе:
public function test_order_can_be_created()
{
// Arrange — готовим окружение
$user = User::factory()->create();
// Act — одно действие
$response = $this->actingAs($user)->post('/orders', [
'items' => [['sku' => 'ABC-1', 'qty' => 2]],
]);
// Assert — проверяем результат по нескольким осям
$response->assertStatus(201); // HTTP-статус
$order = Order::latest()->first();
$this->assertInstanceOf(Order::class, $order); // тип объекта
$this->assertEquals($user->id, $order->user_id); // связка user → order
$this->assertDatabaseHas('orders', [ // запись в БД
'user_id' => $user->id,
'status' => 'pending',
]);
}
Что делает каждая проверка:
assertStatus(201)— HTTP-ответ 201 Created (методTestResponse, не PHPUnit).assertInstanceOf(Order::class, $order)—$order— экземплярOrder(или наследника). Еслиlatest()->first()вернулnull— тест упадёт здесь с понятным сообщением, а не на следующей строке с непонятным «Trying to get property of non-object».assertEquals($user->id, $order->user_id)— заказ действительно привязан к тому пользователю, который его создавал.assertDatabaseHas(...)— Laravel-специфичная проверка: в таблицеordersреально лежит строка с этими полями. Более надёжно, чем полагаться только на in-memory объект.
- Unit-тесты — отдельные классы и методы (сервисы, actions, модели). Обычно только PHPUnit-ассерты:
assertEquals,assertInstanceOf,assertTrue,assertCount. - Feature-тесты — маршрут → контроллер → middleware → БД. PHPUnit-ассерты + Laravel:
assertStatus,assertRedirect,assertJson,assertJsonStructure,assertDatabaseHas,assertSee.
class UserTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Runs before EACH test
$this->user = User::factory()->create();
}
protected function tearDown(): void
{
// Runs after EACH test
parent::tearDown();
}
public function test_user_exists()
{
$this->assertNotNull($this->user);
}
}
/**
* @dataProvider emailProvider
*/
public function test_email_validation($email, $isValid)
{
$result = filter_var($email, FILTER_VALIDATE_EMAIL);
$this->assertEquals($isValid, $result !== false);
}
public static function emailProvider()
{
return [
'valid email' => ['user@example.com', true],
'invalid email' => ['invalid@', false],
'no domain' => ['user@', false],
];
}
# Run all tests
php artisan test
# Run specific test class
php artisan test tests/Unit/UserTest.php
# Run tests matching pattern
php artisan test --filter=email_validation
# Run tests in parallel
php artisan test --parallel
# Run with coverage
php artisan test --coverage
# Run specific test group
php artisan test --group=auth
#[Group('auth')]
public function test_user_login()
{
// ...
}
Task: Create a test file for a simple Calculator class with methods add(), subtract(), multiply(), divide(). Write tests using data providers to test multiple scenarios for each method. Run with coverage.
Hint: Create the Calculator class first, then write feature tests using test() function.
Unit tests focus on small, isolated pieces of code. Test business logic, calculations, and pure functions.
- Pure functions: Functions that always return the same output for same input
- Service methods: Business logic in service classes
- Calculations: Complex formulas and computations
- Validators: Custom validation rules
- Model methods: Computed properties, relationships (mocked)
Unit tests must be isolated. Use Mockery to mock dependencies:
use Mockery;
class OrderServiceTest extends TestCase
{
public function test_order_total_with_discount()
{
// Mock the discount service
$discountService = Mockery::mock(DiscountService::class);
$discountService
->shouldReceive('getDiscount')
->with(100)
->andReturn(10); // 10% discount
$service = new OrderService($discountService);
$total = $service->calculateTotal(100);
$this->assertEquals(90, $total);
}
}
// Service class
class OrderCalculator
{
public function calculateTotal(Order $order, TaxService $taxService): float
{
$subtotal = $order->items->sum('price');
$tax = $taxService->calculate($subtotal);
return $subtotal + $tax;
}
}
// Test
class OrderCalculatorTest extends TestCase
{
public function test_calculates_total_with_tax()
{
$taxService = Mockery::mock(TaxService::class);
$taxService->shouldReceive('calculate')->with(100)->andReturn(10);
$order = Mockery::mock(Order::class);
$order->items = collect([
['price' => 50],
['price' => 50],
]);
$calculator = new OrderCalculator();
$total = $calculator->calculateTotal($order, $taxService);
$this->assertEquals(110, $total);
}
}
class StringHelper
{
public static function slugify(string $text): string
{
return strtolower(
preg_replace('/[^a-z0-9]+/', '-', $text)
);
}
}
// No mocking needed for pure functions!
class StringHelperTest extends TestCase
{
public function test_slugify()
{
$this->assertEquals('hello-world', StringHelper::slugify('Hello World'));
$this->assertEquals('test-123', StringHelper::slugify('Test 123!'));
}
}
Feature tests test entire HTTP requests/responses. They exercise controllers, middleware, and routing.
use Tests\TestCase;
class UserControllerTest extends TestCase
{
public function test_can_view_users()
{
$response = $this->get('/api/users');
$response->assertStatus(200);
$response->assertJson(['data' => []]);
}
public function test_can_create_user()
{
$response = $this->post('/api/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
]);
$response->assertStatus(201);
$response->assertJson([
'name' => 'John Doe',
'email' => 'john@example.com',
]);
}
}
| Method | Usage |
|---|---|
$this->get() |
GET request |
$this->post() |
POST request |
$this->put() |
PUT request (full update) |
$this->patch() |
PATCH request (partial update) |
$this->delete() |
DELETE request |
$response = $this->get('/users/1');
$response->assertStatus(200); // Check HTTP status
$response->assertOk(); // Assert 200
$response->assertJson(['name' => 'John']); // Check JSON
$response->assertJsonPath('user.id', 1); // Check nested path
$response->assertSee('John'); // Check visible text
$response->assertRedirect('/dashboard'); // Check redirect
$response->assertCookie('session_id'); // Check cookie
$response->assertHeader('Content-Type'); // Check header
$response->assertJsonCount(5, 'data'); // Check array length
class ProtectedRouteTest extends TestCase
{
use RefreshDatabase;
public function test_unauthorized_user_cannot_access()
{
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
}
public function test_authenticated_user_can_access()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
}
public function test_only_admin_can_delete()
{
$user = User::factory()->create(['role' => 'user']);
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($user)->delete('/users/1')->assertForbidden();
$this->actingAs($admin)->delete('/users/1')->assertOk();
}
}
Use RefreshDatabase trait to reset database after each test:
class UserApiTest extends TestCase
{
use RefreshDatabase;
public function test_create_user()
{
User::factory()->count(5)->create();
$response = $this->post('/api/users', ['name' => 'Jane']);
$this->assertDatabaseCount('users', 6);
$this->assertDatabaseHas('users', ['name' => 'Jane']);
}
}
class ProductApiTest extends TestCase
{
use RefreshDatabase;
public function test_api_returns_paginated_products()
{
Product::factory()->count(15)->create();
$response = $this->getJson('/api/products?page=1');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'price']
],
'meta' => ['total', 'per_page', 'current_page']
])
->assertJsonCount(10, 'data');
}
}
Task: Write a complete feature test for a Blog API with endpoints: GET /blogs (paginated), POST /blogs (create), GET /blogs/{id}, PUT /blogs/{id}, DELETE /blogs/{id}. Test authentication, validation errors, and successful operations.
Fakes: Built-in Laravel test doubles that simulate behavior (Event::fake, Queue::fake). Use these when available.
Mocks (Mockery): Manual mocks for custom dependencies. Use when fakes don't exist.
use Illuminate\Support\Facades\Event;
class OrderTest extends TestCase
{
public function test_dispatches_order_created_event()
{
Event::fake();
Order::create(['total' => 100]);
Event::assertDispatched(OrderCreated::class);
}
public function test_event_contains_correct_data()
{
Event::fake();
$order = Order::create(['total' => 100]);
Event::assertDispatched(OrderCreated::class, function ($event) use ($order) {
return $event->order->id === $order->id;
});
}
}
use Illuminate\Support\Facades\Queue;
class EmailTest extends TestCase
{
public function test_sends_welcome_email_via_queue()
{
Queue::fake();
User::create(['email' => 'user@example.com']);
Queue::assertPushed(SendWelcomeEmail::class);
}
public function test_job_pushed_to_correct_queue()
{
Queue::fake();
User::create(['email' => 'user@example.com']);
Queue::assertPushedOn('emails', SendWelcomeEmail::class);
}
}
use Illuminate\Support\Facades\Notification;
class OrderNotificationTest extends TestCase
{
public function test_sends_notification_on_order_shipped()
{
Notification::fake();
$order = Order::factory()->create();
$order->update(['status' => 'shipped']);
Notification::assertSentTo($order->user, OrderShipped::class);
}
}
use Illuminate\Support\Facades\Mail;
class MailTest extends TestCase
{
public function test_welcome_mail_sent()
{
Mail::fake();
Mail::to('user@example.com')->send(new WelcomeEmail('John'));
Mail::assertSent(WelcomeEmail::class, function ($mail) {
return $mail->hasTo('user@example.com');
});
}
}
use Illuminate\Support\Facades\Http;
class PaymentServiceTest extends TestCase
{
public function test_payment_gateway_integration()
{
Http::fake([
'api.payment.com/*' => Http::response(['status' => 'success'], 200),
]);
$result = PaymentService::charge('4111111111111111', 100);
$this->assertTrue($result);
Http::assertSent(function ($request) {
return $request->url() == 'api.payment.com/charge';
});
}
}
use Illuminate\Support\Facades\Storage;
class UploadTest extends TestCase
{
public function test_file_upload()
{
Storage::fake('avatars');
$response = $this->post('/upload', [
'avatar' => UploadedFile::fake()->image('avatar.jpg'),
]);
Storage::disk('avatars')->assertExists('avatar.jpg');
}
}
Task: Write a test for a UserService that: 1) Creates a user, 2) Sends welcome email via queue, 3) Dispatches UserCreated event. Test all three behaviors are triggered without actually sending emails or processing queues.
Red: Write a failing test that describes desired behavior.
Green: Write minimal code to make the test pass.
Refactor: Improve code quality while keeping tests passing.
class DiscountServiceTest extends TestCase
{
public function test_first_time_customer_gets_10_percent_discount()
{
$service = new DiscountService();
$customer = Customer::factory()->create(['orders_count' => 0]);
$discount = $service->calculateDiscount($customer);
$this->assertEquals(0.10, $discount);
}
}
class DiscountService
{
public function calculateDiscount(Customer $customer): float
{
return 0.10; // Hardcoded, but test passes!
}
}
public function test_returning_customer_with_3_orders_gets_15_percent()
{
$service = new DiscountService();
$customer = Customer::factory()->create(['orders_count' => 3]);
$discount = $service->calculateDiscount($customer);
$this->assertEquals(0.15, $discount);
}
public function calculateDiscount(Customer $customer): float
{
if ($customer->orders_count === 0) {
return 0.10;
}
if ($customer->orders_count >= 3) {
return 0.15;
}
return 0.05;
}
public function calculateDiscount(Customer $customer): float
{
return match (true) {
$customer->orders_count === 0 => 0.10,
$customer->orders_count >= 3 => 0.15,
default => 0.05,
};
}
- Complex business logic: TDD helps design the API and edge cases
- Refactoring: Tests ensure behavior doesn't change
- Legacy code: Write tests before modifying untested code
- Simple CRUD operations with standard patterns
- Throwaway proof-of-concept code
- Heavy UI work with frequent visual changes
Task: Use TDD to build a Product Recommendation system. Start with: 1) Customers get recommendations based on purchase history, 2) Similar products are ranked by relevance, 3) Bought items are excluded. Write tests first, then implementation.
Pest is a modern PHP testing framework built on PHPUnit. It's cleaner, more expressive syntax with focus on readability.
composer require --dev pestphp/pest pestphp/pest-plugin-laravel
Pest uses simple test() and it() functions instead of class-based tests:
// Pest syntax
test('user can login', function () {
$user = User::factory()->create(['password' => 'secret']);
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'secret',
]);
$response->assertRedirect('/dashboard');
});
// Alternative: More readable with it()
it('logs in with valid credentials', function () {
$user = User::factory()->create(['password' => 'secret']);
$this->post('/login', [
'email' => $user->email,
'password' => 'secret',
])->assertRedirect('/dashboard');
});
// Old PHPUnit style
$this->assertEquals(5, $result);
$this->assertTrue($user->isAdmin());
// Pest expectations (more fluent)
expect($result)->toBe(5);
expect($user->isAdmin())->toBeTrue();
expect($users)->toHaveCount(3);
expect($email)->toMatch('/\S+@\S+\.\S+/');
Pest allows skipping tests with ->skip() or marking as ->todo():
it('validates email format', function () {
expect('invalid@')->not()->toMatch('/\S+@\S+\.\S+/');
})->skip();
it('implements payment processing', function () {
// Not implemented yet
})->todo();
Pest can test code architecture and dependencies:
use Pest\Arch\Targets\Target;
// Ensure Services don't depend on Controllers
arch()
->set('Services', 'app/Services')
->set('Controllers', 'app/Http/Controllers')
->expect('Services')
->not()
->toUse('Controllers');
// Ensure proper domain structure
arch()
->set('Models', 'app/Models')
->set('Jobs', 'app/Jobs')
->expect('Jobs')
->toUse('Models');
You can mix PHPUnit and Pest in the same project. Gradually migrate:
# Convert existing tests
composer require pestphp/pest-plugin-drift --dev
php artisan pest:drift
Task: Take your PHPUnit tests from previous exercises and rewrite using Pest syntax. Use expectations API instead of assertions. Add arch tests to ensure your services don't depend on controllers.
│ E2E Tests │ Few, slow (seconds)
│ (UI/Browser) │ Slow feedback
└────────────────┘
┌──────────────────┐
│ Integration │ Some, medium speed
│ (API, Database) │ 100s milliseconds
└──────────────────┘
┌────────────────────┐
│ Unit Tests │ Many, fast (ms)
│ (Business Logic) │ Instant feedback
└────────────────────┘
- Goal: Test isolated business logic
- Coverage: 70-80% of code should be here
- Speed: Run in milliseconds
- Examples: Service methods, calculations, validators
- Isolation: Mock all dependencies
- Goal: Test multiple components working together
- Coverage: 15-20% of code
- Speed: 100s of milliseconds
- Examples: Repository queries, API endpoints, database transactions
- Scope: Use real database or in-memory SQLite
- Goal: Test complete user workflows
- Coverage: 5-10% of code (critical happy paths)
- Speed: Seconds per test
- Examples: Complete purchase flow, user registration → login
- Tools: Laravel Dusk, Selenium, Playwright
# Generate coverage report
php artisan test --coverage
# Expected by level:
# - Critical business logic: 80-95% coverage
# - Controllers/actions: 60-80% coverage
# - Simple getters/setters: 20-40% coverage
# - Overall target: 60-75% for health
// phpunit.xml
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">app</directory>
</include>
<exclude>
<directory>app/Console</directory>
<directory>app/Exceptions</directory>
</exclude>
</coverage>
Task: Take an existing Laravel project (or create a simple one with 3 features). Write tests for it following the pyramid: 1) 10+ unit tests, 2) 3-4 integration tests, 3) 1 E2E test. Generate coverage report and identify gaps.
Image: Blueprint/template with OS, dependencies, code (immutable).
Container: Running instance of an image (mutable runtime).
# Images are like classes, containers are instances
docker images # List images
docker run image-name # Create and run container from image
docker ps # List running containers
docker ps -a # List all containers
| Instruction | Purpose | Example |
|---|---|---|
FROM |
Base image | FROM php:8.2-fpm |
RUN |
Execute command | RUN apt-get install -y curl |
COPY |
Copy from host to image | COPY . /app |
WORKDIR |
Set working directory | WORKDIR /app |
EXPOSE |
Expose port (documentation) | EXPOSE 9000 |
CMD |
Default command | CMD ["php-fpm"] |
ENTRYPOINT |
Force command | ENTRYPOINT ["php"] |
FROM php:8.2-fpm
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
libpq-dev \
libzip-dev \
&& docker-php-ext-install pdo_pgsql zip
# Copy application
COPY . .
# Install composer dependencies
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
RUN composer install --no-dev --optimize-autoloader
EXPOSE 9000
CMD ["php-fpm"]
Each instruction creates a layer. Docker caches layers for faster builds:
# Bad: System packages + composer install in one layer
RUN apt-get install -y curl && composer install
# Good: Separate layers for better caching
RUN apt-get update && apt-get install -y curl
RUN composer install
# Better: Install composer deps with cached composer.lock
COPY composer.* ./
RUN composer install --no-dev
COPY . .
RUN npm run build # Only rebuilds if source changes
Use multiple stages to reduce final image size:
# Stage 1: Builder
FROM php:8.2 as builder
WORKDIR /build
COPY . .
RUN composer install --no-dev --optimize-autoloader
RUN npm install && npm run build
# Stage 2: Runtime (smaller)
FROM php:8.2-fpm
WORKDIR /app
COPY --from=builder /build /app
EXPOSE 9000
CMD ["php-fpm"]
# Build image
docker build -t myapp:1.0 .
# Build with build args
docker build --build-arg PHP_VERSION=8.1 -t myapp .
# Run container
docker run -p 9000:9000 myapp:1.0
# Run with volume (live code)
docker run -v /path/to/app:/app -p 9000:9000 myapp:1.0
# Run in background
docker run -d -p 9000:9000 myapp:1.0
# Execute command in running container
docker exec container-name php artisan migrate
Task: Create a multi-stage Dockerfile for Laravel that: 1) Builds assets in first stage, 2) Installs composer dependencies, 3) Final stage only contains runtime files. Verify image size reduction.
version: '3.8'
services:
app:
build: .
container_name: laravel_app
working_dir: /app
volumes:
- ./:/app
ports:
- "9000:9000"
environment:
- DB_HOST=mysql
- REDIS_HOST=redis
depends_on:
- mysql
- redis
networks:
- laravel
nginx:
image: nginx:alpine
container_name: laravel_nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./:/app
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
- ./docker/certs:/etc/nginx/certs
depends_on:
- app
networks:
- laravel
mysql:
image: mysql:8.0
container_name: laravel_mysql
environment:
MYSQL_DATABASE: laravel
MYSQL_USER: laravel
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: root
volumes:
- mysql_data:/var/lib/mysql
- ./docker/mysql.cnf:/etc/mysql/conf.d/my.cnf
ports:
- "3306:3306"
networks:
- laravel
redis:
image: redis:7-alpine
container_name: laravel_redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- laravel
volumes:
mysql_data:
redis_data:
networks:
laravel:
driver: bridge
# Start all services
docker-compose up -d
# View logs
docker-compose logs app
docker-compose logs -f nginx # Follow logs
# Run one-off command
docker-compose exec app php artisan migrate
docker-compose exec mysql mysql -u root -proot laravel
# Stop services
docker-compose down
# Remove volumes (deletes data)
docker-compose down -v
# Rebuild images
docker-compose build --no-cache
server {
listen 80;
server_name localhost;
root /app/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
}
Laravel Sail is a pre-built Docker setup for Laravel:
# Install Sail in new project
composer require laravel/sail --dev
php artisan sail:install
# Start services
./vendor/bin/sail up -d
# Run commands
./vendor/bin/sail artisan migrate
./vendor/bin/sail tinker
Task: Create a complete docker-compose.yml for a Laravel project with: 1) PHP-FPM container, 2) Nginx reverse proxy, 3) PostgreSQL database, 4) Redis cache. Get a fresh Laravel app running in containers with all services communicating.
- main: Production-ready code
- develop: Integration branch
- feature/name: New features from develop
- hotfix/name: Critical fixes from main
- release/version: Release preparation
# GitFlow workflow
git checkout -b feature/user-auth develop
# ... make changes ...
git commit -m "feat: add user authentication"
git push origin feature/user-auth
# Create pull request, code review, then merge to develop
git checkout develop && git pull
git merge --no-ff feature/user-auth
git push origin develop
- Short-lived feature branches (1-2 days max)
- Frequent merges to main
- Feature flags for incomplete features
- Requires strong CI/CD pipeline
# Trunk-based: Quick iterations
git checkout -b feature/payment-processing
# ... 2 hours of work ...
git commit -m "feat: stripe payment integration"
git push && create PR
# Merge and immediately deploy
git checkout main && git pull
git merge feature/payment-processing
git push # CI/CD deploys automatically
# Creates merge commit
git checkout main
git merge feature/x
# History shows branching:
# * commit (merge commit)
# |\
# | * feature commit
# | * feature commit
# |/
# * main commit
# Replay commits on top of main
git rebase main
# History is linear:
# * feature commit 2
# * feature commit 1
# * main commit
# Interactive rebase to squash
git rebase -i HEAD~3
# Mark commits:
# pick abc123 First commit
# squash def456 Second commit (combine with above)
# squash ghi789 Third commit (combine with above)
# Result: Single clean commit
# Apply specific commit to current branch
git cherry-pick abc123
# Useful for backporting fixes to release branch
git checkout release/v2.0
git cherry-pick bugfix-commit-hash
# Save work without committing
git stash
# Switch to another branch, fix urgent issue
git checkout hotfix/critical-bug
# ... fix and commit ...
# Return to original work
git checkout feature/my-feature
git stash pop
Standard commit format for clarity:
feat: add user authentication with OAuth
fix: resolve race condition in cache layer
docs: update API documentation
style: format code with PHP-CS-Fixer
refactor: simplify payment service
test: add unit tests for OrderCalculator
chore: upgrade Laravel to 10.0
# Breaking changes
feat!: change API response structure
#!/bin/sh
# .git/hooks/pre-commit
# Run PHP linter
./vendor/bin/php-cs-fixer fix --dry-run
if [ $? -ne 0 ]; then
echo "PHP-CS-Fixer failed. Run: ./vendor/bin/php-cs-fixer fix"
exit 1
fi
# Run tests
php artisan test --stop-on-failure
if [ $? -ne 0 ]; then
echo "Tests failed. Fix them before committing."
exit 1
fi
exit 0
Task: 1) Create feature branches following GitFlow, 2) Rebase to squash messy commits, 3) Use cherry-pick to backport fix to release branch, 4) Write conventional commit messages throughout.
Create .github/workflows/ci.yml:
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: pdo, pdo_mysql, redis
ini-values: post_max_size=256M
- name: Cache Composer packages
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
- name: Install dependencies
run: composer install --prefer-dist
- name: Lint PHP
run: |
./vendor/bin/php-cs-fixer fix --dry-run
./vendor/bin/phpstan analyse
- name: Run tests
run: php artisan test --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install
- run: php artisan test tests/Unit --parallel
integration-tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: root
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
ports:
- 3306:3306
steps:
- uses: actions/checkout@v3
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install
- run: php artisan test tests/Feature
name: Deploy to Production
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install
- run: php artisan test
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /var/www/app
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan cache:clear
php artisan config:cache
php artisan route:cache
sudo systemctl restart app-queue
| Action | Purpose |
|---|---|
actions/checkout |
Clone repository |
shivammathur/setup-php |
Install PHP with extensions |
actions/cache |
Cache dependencies |
codecov/codecov-action |
Upload coverage reports |
appleboy/ssh-action |
SSH into server for deployment |
Task: Create a GitHub Actions workflow that: 1) Runs tests on push, 2) Tests multiple PHP versions in parallel, 3) Lints and analyzes code, 4) Uploads coverage, 5) Deploys to staging on PR merge.
| Command | Purpose | Example |
|---|---|---|
ls |
List files/directories | ls -la /var/www |
cd |
Change directory | cd /app && pwd |
grep |
Search text patterns | grep "error" app.log |
find |
Find files | find . -name "*.log" -type f |
chmod |
Change file permissions | chmod 755 script.sh |
chown |
Change file owner | chown www-data:www-data /app |
ps |
List processes | ps aux | grep php |
kill |
Terminate process | kill -9 1234 |
systemctl |
Manage services | systemctl restart nginx |
tail |
View end of file | tail -f /var/log/app.log |
# Permission format: user | group | others
# Read (r) = 4, Write (w) = 2, Execute (x) = 1
chmod 755 script.sh # User: rwx (7), Group: r-x (5), Others: r-x (5)
chmod 644 file.txt # User: rw- (6), Group: r-- (4), Others: r-- (4)
chmod 700 private # User: rwx (7), Group: --- (0), Others: --- (0)
# For directories:
chmod 755 /var/www # rwx for user, rx for group/others (can enter)
chmod 700 ~/.ssh # Only user can enter
# Generate SSH key pair
ssh-keygen -t ed25519 -C "deploy@server"
# Generates: ~/.ssh/id_ed25519 (private) and ~/.ssh/id_ed25519.pub (public)
# Add public key to server
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.com
# SSH login without password
ssh -i ~/.ssh/id_ed25519 user@server.com
# Secure SSH config (~/.ssh/config)
Host production
HostName prod.example.com
User deploy
IdentityFile ~/.ssh/id_ed25519
# Edit crontab
crontab -e
# Cron syntax: minute hour day month dayofweek command
# Run Laravel scheduler every minute
* * * * * cd /app && php artisan schedule:run >> /dev/null 2>&1
# Examples:
0 2 * * * /backup.sh # Daily at 2 AM
*/5 * * * * php /app/check.php # Every 5 minutes
0 0 1 * * /monthly-task.sh # First day of month
0 0 * * 1 /weekly-task.sh # Every Monday
# View active crons
crontab -l
# Create service file
sudo nano /etc/systemd/system/laravel-queue.service
[Unit]
Description=Laravel Queue Worker
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/app
ExecStart=/usr/bin/php artisan queue:work redis --sleep=3 --tries=3
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
# Enable and start
sudo systemctl enable laravel-queue
sudo systemctl start laravel-queue
sudo systemctl status laravel-queue
# Common log locations
/var/log/syslog # System logs
/var/log/auth.log # Authentication logs
/var/log/nginx/access.log # Nginx access
/var/log/nginx/error.log # Nginx errors
/var/log/php-fpm.log # PHP-FPM logs
# Monitor logs in real-time
tail -f /var/log/nginx/error.log
# Search logs
grep "GET /api" /var/log/nginx/access.log
grep -i error /var/log/app.log | tail -20
Task: 1) SSH into a test server, 2) Create appropriate directories with proper permissions, 3) Set up SSH key authentication, 4) Create a cron job for Laravel scheduler, 5) Create a systemd service for queue worker.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
root /var/www/app/public;
index index.php index.html;
# SSL certificates
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ... rest of config
}
server {
root /var/www/app/public;
# Exact match (highest priority)
location = /admin {
return 403;
}
# Prefix match
location /api {
# API routes are proxied to PHP
try_files $uri $uri/ /index.php?$query_string;
}
# Regex match (case-sensitive)
location ~ \.php$ {
# PHP files are processed by FPM
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
# Regex match (case-insensitive)
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
# Static files - enable caching
expires 30d;
add_header Cache-Control "public, immutable";
}
# Everything else
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
upstream php_upstream {
server 127.0.0.1:9000;
server 127.0.0.1:9001; # Load balance across FPM instances
keepalive 32;
}
server {
listen 80;
server_name app.local;
root /app/public;
location ~ \.php$ {
fastcgi_pass php_upstream;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Improvements
fastcgi_buffering off;
fastcgi_request_buffering off;
fastcgi_keep_conn on;
include fastcgi_params;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss;
server {
# Prevent MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# Enable XSS protection
add_header X-XSS-Protection "1; mode=block" always;
# Clickjacking protection
add_header X-Frame-Options "SAMEORIGIN" always;
# HSTS (force HTTPS)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" always;
}
# Validate syntax
nginx -t
# Reload without dropping connections
nginx -s reload
# Graceful restart
systemctl reload nginx
# View active config
nginx -T | grep -A 20 "server {"
Task: Create nginx config for a Laravel app with: 1) HTTP → HTTPS redirect, 2) SSL certificates, 3) PHP-FPM reverse proxy, 4) Static file caching, 5) Gzip compression, 6) Security headers, 7) Load balancing across multiple FPM instances.
Deploying without interrupting current users:
# Traditional: Downtime during deployment
git pull
composer install
php artisan migrate
# Site is down during these operations
# Zero-downtime: Use symlinks
# Current structure:
# /var/www/
# releases/
# release-2024-01-15/
# release-2024-01-14/
# app -> releases/release-2024-01-14
# During deployment:
git pull origin main
composer install
php artisan migrate # Migrations are backwards compatible
# Atomically switch symlink
ln -sfn releases/release-2024-01-15 app
# If issue, revert instantly:
ln -sfn releases/release-2024-01-14 app
// deploy.php
use Deployer as dep;
dep\config()
->set('application', 'myapp')
->set('repository', 'git@github.com:user/myapp.git')
->set('shared_files', ['.env'])
->set('shared_dirs', ['storage', 'bootstrap/cache'])
->set('writable_dirs', ['storage']);
// Hosts
dep\host('production')
->set('hostname', 'prod.example.com')
->set('user', 'deploy')
->set('deploy_path', '/var/www/app');
// Tasks
dep\task('deploy', [
'deploy:prepare',
'deploy:lock',
'deploy:release',
'deploy:update_code',
'deploy:shared',
'deploy:vendors',
'deploy:run_migrations',
'deploy:cache_config',
'deploy:cache_routes',
'deploy:symlink',
'deploy:unlock',
])->desc('Deploy application');
dep\task('deploy:run_migrations', function () {
dep\run('cd {{release_path}} && php artisan migrate --force');
});
// Deploy
// deployer deploy
Run these commands after deploying new code:
# Migrations (most important)
php artisan migrate --force
# Clear old caches
php artisan cache:clear
php artisan view:clear
# Recache for performance
php artisan config:cache
php artisan route:cache
php artisan event:cache
# Restart background workers
sudo systemctl restart laravel-queue
sudo systemctl restart laravel-scheduler
# Verify deployment
curl https://example.com/api/health
| Component | Requirement |
|---|---|
| PHP | 8.1+ (8.2+ recommended) |
| Extensions | pdo, mbstring, json, bcmath, tokenizer, xml, ctype, fileinfo, filter, hash |
| Web Server | Nginx 1.20+ or Apache 2.4+ |
| Database | MySQL 8.0+ / PostgreSQL 11+ / SQLite 3.8+ |
| Cache | Redis 5+ or Memcached 1.4+ |
| Storage | SSD with 20GB+ for app |
- Laravel Forge: Server management, zero-downtime deploys, SSL, backups, monitoring
- DigitalOcean App Platform: Fully managed, auto-scaling, integrated database
- AWS (Elastic Beanstalk): Auto-scaling, load balancing, managed infrastructure
- Heroku: Simplest, but more expensive (buildpack approach)
Task: Set up Deployer for a Laravel project with: 1) Symlink-based releases, 2) Shared files/dirs, 3) Automated migrations, 4) Config/route caching, 5) Queue restart. Deploy and verify zero downtime.
Powerful debugging tool for Laravel applications:
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
# Access at: http://localhost:8000/telescope
Monitor:
- HTTP requests & responses
- Database queries
- Cache hits/misses
- Jobs & events
- Log entries
- Exceptions
composer require barryvdh/laravel-debugbar --dev
# Access toolbar at bottom of page in dev
Shows:
- Execution time
- Memory usage
- Database queries with execution time
- View variables
- Route information
composer require sentry/sentry-laravel
# Generate config
php artisan vendor:publish --provider="Sentry\Laravel\ServiceProvider"
# Configure .env
SENTRY_DSN=https://your-key@sentry.io/project-id
Automatically tracks:
- Exceptions and errors
- Performance issues
- User sessions
- Breadcrumbs (event trail)
// Problem: N+1 query
$users = User::all();
foreach ($users as $user) {
echo $user->profile->bio; // Executes query for each user!
}
// Solution: Eager load
$users = User::with('profile')->get();
foreach ($users as $user) {
echo $user->profile->bio; // No extra queries
}
// Detect in Laravel Telescope: View queries tab to spot duplicate patterns
Tools like Datadog or New Relic monitor:
- Transaction performance
- Database performance
- CPU & memory usage
- Error rates
- Throughput and latency
# Example: Datadog PHP agent
wget https://github.com/DataDog/dd-trace-php/releases/download/0.89.0/dd-library-php-0.89.0.tar.gz
tar -xf dd-library-php-0.89.0.tar.gz
cp dd-library-php /opt/datadog/
# Configure in php.ini
extension=/opt/datadog/dd_trace.so
datadog.trace.sample_rate=1.0
Centralize logs from all servers:
# Using ELK Stack (Elasticsearch, Logstash, Kibana)
# Send Laravel logs to Logstash
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single', 'logstash'],
],
'logstash' => [
'driver' => 'monolog',
'handler' => \Monolog\Handler\SocketHandler::class,
'handler_with' => [
'host' => 'logstash.example.com:5000',
],
],
]
Task: Set up comprehensive monitoring: 1) Install Telescope and verify it captures requests/queries, 2) Detect and fix N+1 query, 3) Configure Sentry for error tracking, 4) Monitor a slow endpoint and optimize it, 5) Set up Debugbar to profile performance.