<< Home

Chapter 22: Frameworks Overview

PHP has a healthy ecosystem of frameworks that help you deliver projects with predictable structure and batteries included. This chapter gives you a practical overview of Laravel, Symfony, and Slim; it also shows how to choose a framework, how to test inside each ecosystem, and how to migrate between them without rewriting everything at once.

💡 Treat a framework as infrastructure. Keep your core business rules in plain PHP so you can reuse them across console, queue workers, and web entry points.

Laravel: Routing, Eloquent, and Blade

Laravel provides batteries that fit together out of the box. You get routing, an expressive ORM named Eloquent, a templating system named Blade, queues, mail, caching, and standard project structure. The defaults encourage sensible conventions for modern applications.

Registering routes and controllers clearly

Routes map verbs and paths to controller actions. Small projects can use closures; larger projects use dedicated controllers so you can test actions in isolation.

// routes/web.php
use App\Http\Controllers\ArticleController;
use Illuminate\Support\Facades\Route;

Route::get('/articles', [ArticleController::class, 'index']);
Route::get('/articles/{id}', [ArticleController::class, 'show']);
Route::post('/articles', [ArticleController::class, 'store']);
// app/Http/Controllers/ArticleController.php
namespace App\Http\Controllers;

use App\Models\Article;
use Illuminate\Http\Request;

class ArticleController extends Controller
{
  public function index()
  {
    return view('articles.index', ['articles' => Article::latest()->paginate(10)]);
  }

  public function show(int $id)
  {
    $article = Article::findOrFail($id);
    return view('articles.show', compact('article'));
  }

  public function store(Request $request)
  {
    $data = $request->validate(['title' => 'required|string|max:255']);
    $article = Article::create($data);
    return redirect('/articles/' . $article->id);
  }
}

Modeling data with Eloquent conveniently

Eloquent models wrap tables and relationships. You define fillable fields and relationships; then you compose queries fluently. Under the hood it uses a query builder so you can drop to raw queries when needed.

// app/Models/Article.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Article extends Model
{
  protected $fillable = ['title', 'body'];

  public function author()
  {
    return $this->belongsTo(User::class);
  }
}

Rendering views with Blade templates

Blade templates compile to cached PHP. You use sections, layouts, and components to keep markup tidy while still writing plain HTML.

<!-- resources/views/articles/index.blade.php -->
@extends('layouts.app')

@section('content')
  <h1>Articles</h1>

  @foreach ($articles as $article)
    <article>
      <h2>{{ $article->title }}</h2>
      <p>{{ Str::limit($article->body, 120) }}</p>
    </article>
  @endforeach

  {{ $articles->links() }}
@endsection
⚠️ Keep validation and authorization in controllers or form requests, not in Blade. Templates should only present data that is already safe to render.

Symfony: Components and Flex

Symfony is a collection of well tested components and a full stack framework. You can assemble only what you need, or you can start with the standard skeleton that includes HTTP kernel, routing, dependency injection, console, and Twig for views. Flex streamlines installation and configuration through recipes.

Routing and controllers with attributes

Controllers are simple classes; methods receive the request and return a response. Attributes on methods make routes discoverable and local to the action.

// src/Controller/ArticleController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class ArticleController extends AbstractController
{
  #[Route('/articles', name: 'article_index', methods: ['GET'])]
  public function index(): Response
  {
    return $this->render('article/index.html.twig', ['articles' => []]);
  }
}

Composing with components and Flex recipes

You can install components like symfony/http-client, mailer, or messenger as needed. Flex recipes create configuration files and register services so you can focus on your code rather than wiring.

# composer.json snippet …
composer require symfony/orm-pack
composer require symfony/maker-bundle --dev
💡 Use maker commands for consistent scaffolding. Generators save time and ensure new classes are registered in the container correctly.

Slim and Microframework Patterns

Slim focuses on fast routing and middleware with minimal opinions. You bring your preferred template engine, ORM, and container. This works well for APIs and services that need a small surface area with predictable behavior.

Defining routes and middleware succinctly

Middleware wraps your application like layers of an onion. You can add error handlers, authentication, and JSON emission in small reusable functions.

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;

require __DIR__ . '/vendor/autoload.php';

$app = AppFactory::create();

$app->addBodyParsingMiddleware();

$app->get('/articles', function (Request $req, Response $res) {
  $payload = json_encode(['data' => [['id' => 1, 'title' => 'Hello']]]);
  $res->getBody()->write($payload);
  return $res->withHeader('Content-Type', 'application/json');
});

$app->run();

Wiring a container for services

Bring a PSR-11 container to manage services and configuration. This keeps construction logic in one place and makes testing easier.

use DI\Container;
use Slim\Factory\AppFactory;

$container = new Container();
$container->set('db.dsn', 'sqlite::memory:');
AppFactory::setContainer($container);

$app = AppFactory::create();
// resolve services via $this->get('db.dsn') inside route callables when needed
⚠️ Microframeworks do not restrict structure. Establish your own folders for domains, actions, and templates early so growth does not turn into a tangle.

Selecting a Framework

Choose based on team experience, project lifespan, ecosystem features, and operational needs. Laravel favors rapid delivery with strong defaults. Symfony favors explicit composition and long term maintainability. Slim favors small services where you assemble only what you need.

Comparing strengths at a glance

The following table summarizes common considerations for typical web projects. You can adapt these criteria to your own context.

Criterion Laravel Symfony Slim
Learning curve Gentle with opinionated defaults Moderate with explicit configuration Gentle if you already know PSR stack
Out of the box features Rich batteries included Powerful when you add packs Minimal core; you add pieces
Best fit Full stack apps and dashboards Large codebases and long support horizons APIs and microservices
Customization style Extend conventions Assemble components Pick libraries
💡 Run a one day spike in two candidates. Implement the same slice of functionality and compare the code review notes rather than personal preferences.

Testing Inside Frameworks

Frameworks integrate test runners, HTTP clients, and helpers so you can write fast unit tests and realistic feature tests. Aim for a pyramid with many unit tests and fewer end to end tests that hit a real database or HTTP boundary.

Writing Laravel feature tests with Pest or PHPUnit

Laravel ships with helpers for making requests and asserting responses. You can seed data and use in memory databases for speed where possible.

// tests/Feature/ArticleTest.php
it('lists articles', function () {
  \App\Models\Article::factory()->count(2)->create();
  $resp = $this->get('/articles');
  $resp->assertStatus(200)
       ->assertSee('Articles');
});

Using Symfony test client for HTTP assertions

Symfony provides a kernel test client that boots the container in a test environment. You can request URLs and assert status, headers, and content.

// tests/Controller/ArticleControllerTest.php
namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ArticleControllerTest extends WebTestCase
{
  public function testIndex(): void
  {
    $client = static::createClient();
    $client->request('GET', '/articles');
    $this->assertResponseIsSuccessful();
    $this->assertSelectorExists('h1');
  }
}
⚠️ Do not couple tests to framework internals. Assert public behavior such as status codes and HTML text so refactors remain cheap.

Migration Between Frameworks

Migrations usually happen when requirements change or when teams consolidate stacks. Plan a route that reduces risk by keeping business logic independent from controllers and HTTP wiring. Adopt a strangler pattern that proxies some routes to the new system while the old system continues to serve the rest.

Extracting domain code into plain PHP first

Move services and use cases into framework agnostic classes that depend on interfaces. Adapters in each framework wire those interfaces to HTTP, CLI, or persistence. Once the domain is independent, swapping the web layer is smaller and safer.

// Domain service independent of framework
namespace Domain;

interface ArticleRepository {
  public function find(int $id): ?Article;
  public function save(Article $a): void;
}

final class PublishArticle {
  public function __construct(private ArticleRepository $repo) {}
  public function __invoke(array $input): int {
    // validate and persist
    // return new id
    return 1;
  }
}

Proxying routes during a staged cutover

Introduce an edge proxy that forwards selected paths to the new app. Keep cookies, sessions, and authentication unified at the edge so users do not notice the change while you move endpoints gradually.

💡 Keep an exit plan. Document how to roll back a route to the old system in one step if metrics or logs show problems after a switch.