Praktyczny przewodnik po MVC w PHP dla początkujących webmasterów

0
132
3/5 - (1 vote)

Nawigacja:

Po co w ogóle MVC w PHP początkującemu webmasterowi

Bałagan w jednym pliku PHP – skąd bierze się problem

Typowy start z PHP wygląda podobnie: jeden plik index.php, w nim trochę HTML, trochę PHP, kilka zapytań SQL, obsługa formularza, walidacja, sesja, przekierowania nagłówkami i jeszcze kawałek JavaScriptu wrzucony „na szybko”. Przy małej stronie to działa, lecz przy pierwszej rozbudowie zaczyna się walka z własnym kodem.

Jeśli w jednym pliku miesza się generowanie HTML, operacje na bazie danych, logika walidacji formularzy i zarządzanie sesją, to po kilku tygodniach trudno odpowiedzieć na proste pytania: gdzie jest logowanie użytkownika, gdzie dokładnie zapisuje się nowy wpis na blogu, co się stanie, gdy zmieni się struktura tabeli w bazie? Każda modyfikacja wymaga przewijania jednego wielkiego pliku i szukania fragmentu kodu po komentarzach lub na czuja.

Taki chaos ma też inne konsekwencje. Kopiowanie fragmentów kodu zamiast sensownego podziału, powtarzające się zapytania SQL, trudne do śledzenia błędy związane z tym, że logika wykonuje się w kilku miejscach jednocześnie. Z czasem każda prosta zmiana zabiera coraz więcej czasu, a ryzyko popsucia czegoś rośnie wraz z liczbą plików i „szybkich poprawek”.

MVC w sensie praktycznym – oddzielenie logiki od widoków

Wzorzec projektowy MVC w PHP rozwiązuje ten problem przez wymuszenie prostego podziału ról. Zamiast jednego pliku robiącego wszystko, pojawiają się trzy główne części aplikacji:

  • Model – kod odpowiedzialny za dane i logikę z nimi związaną (np. zapisywanie posta, pobieranie listy użytkowników, zasady walidacji).
  • Widok – szablon HTML z minimalną ilością PHP, który odpowiada tylko za prezentację danych (pętle wyświetlające listę postów, formularze, komunikaty).
  • Kontroler – łączy wszystko: odbiera żądanie HTTP, pobiera dane z modelu, wybiera odpowiedni widok, przekazuje mu dane i zwraca odpowiedź.

W praktyce oznacza to, że jeśli zmienia się wygląd strony, pracuje się głównie w widokach. Jeśli zmienia się reguły biznesowe, dotyka się przede wszystkim modeli. Jeżeli dochodzi nowa funkcjonalność (np. strona z listą postów), powstaje nowa akcja w kontrolerze i odpowiedni widok.

Jak MVC pomaga przy typowych zleceniach webmastera

Przy niewielkich projektach, które przewijają się codziennie w pracy webmastera, podejście MVC daje bardzo konkretne korzyści. Prosta strona firmowa z kilkoma podstronami i formularzem kontaktowym może zostać zorganizowana tak, że każdy typ funkcjonalności ma swoje miejsce: kontroler ContactController, model Message, widok contact/form.php. Łatwiej też rozdzielić prace: jedna osoba poprawia wygląd formularza, druga poprawia walidację i logikę zapisu wiadomości.

Przy prostym blogu struktura MVC pozwala oddzielić operacje na bazie (modele: Post, User), od obsługi konkretnych podstron (kontroler bloga) i od szablonów HTML. Jeśli pojawi się panel logowania, powstaje nowy kontroler AuthController i osobne widoki dla formularza logowania, rejestracji i resetowania hasła. Dzięki temu cały panel administracyjny nie leży w jednym ogromnym pliku admin.php.

Nawet przy małej stronie firmowej MVC ułatwia rozwój. Dziś jest tylko formularz kontaktowy, jutro klient chce prosty panel do wgrywania plików PDF, a pojutrze listę aktualności. Mając bazową strukturę MVC, można te elementy dopisać w logicznie uporządkowany sposób, zamiast „doklejać” kolejne fragmenty do rosnącego proceduralnego potwora.

Kiedy MVC naprawdę pomaga, a kiedy wystarczy jeden plik

Nie każdy projekt wymaga pełnej architektury MVC. Jeżeli przygotowywana jest pojedyncza, statyczna podstrona lub bardzo prosty landing z jednym formularzem i nigdy nie będzie rozwijany, jeden plik z kilkoma funkcjami zupełnie wystarczy. Rozbudowane podejście wprowadziłoby jedynie zbędny narzut.

Jeśli jednak planowane jest:

  • więcej niż kilka podstron z różnymi typami treści,
  • logowanie użytkowników i sesje,
  • panel administracyjny do zarządzania treścią,
  • praca w zespole lub częste zmiany w kodzie,

to prosta architektura MVC zaczyna oszczędzać czas już po pierwszej większej zmianie. Kryterium jest głównie przewidywany rozwój projektu. Jeśli coś może urosnąć, warto od początku trzymać się podziału na modele, widoki i kontrolery.

Podstawy MVC bez żargonu – rozdzielenie ról w aplikacji

Model jako źródło prawdy o danych

Model to nie tylko mapowanie tabeli bazy danych na obiekt. W prostych aplikacjach PHP model staje się „źródłem prawdy” na temat danego typu danych: wie, jak wyglądają kolumny w bazie, jak walidować dane wejściowe i jakie są podstawowe operacje (CRUD: create, read, update, delete).

Przykładowy model Post dla prostego bloga może wyglądać tak:

<?php
// app/Models/Post.php
class Post
{
    protected PDO $db;

    public function __construct(PDO $db)
    {
        $this->db = $db;
    }

    public function findAll(): array
    {
        $stmt = $this->db->query('SELECT * FROM posts ORDER BY created_at DESC');
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function find(int $id): ?array
    {
        $stmt = $this->db->prepare('SELECT * FROM posts WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $post = $stmt->fetch(PDO::FETCH_ASSOC);
        return $post ?: null;
    }

    public function create(array $data): bool
    {
        // prosta walidacja danych może być też na poziomie modelu
        if (empty($data['title']) || empty($data['content'])) {
            return false;
        }

        $stmt = $this->db->prepare(
            'INSERT INTO posts (title, content, created_at) VALUES (:title, :content, NOW())'
        );

        return $stmt->execute([
            'title'   => $data['title'],
            'content' => $data['content'],
        ]);
    }
}

Tutaj model odpowiada za całą komunikację z bazą w kontekście postów. Kontroler nie musi wiedzieć, jak wygląda SQL, ani jak nazywają się kolumny tabeli – wystarczy, że wywoła $postModel->findAll() albo create().

Widok jako szablon bez logiki biznesowej

Widok to plik, który generuje HTML. Może korzystać z prostych konstrukcji PHP: pętli, instrukcji warunkowych, podstawowych funkcji. Nie powinno się tam jednak umieszczać logiki biznesowej, zapytań SQL, pracy na sesji czy obliczeń związanych z zasadami działania aplikacji.

Typowy widok dla listy postów może wyglądać tak:

<!-- app/Views/post/index.php -->
<h1>Lista postów</h1>

<?php if (empty($posts)): ?>
    <p>Brak postów do wyświetlenia.</p>
<?php else: ?>
    <ul>
        <?php foreach ($posts as $post): ?>
            <li>
                <a href="/?route=post/show&id=<?= (int)$post['id']; ?>">
                    <?= htmlspecialchars($post['title']); ?>
                </a>
            </li>
        <?php endforeach; ?>
    </ul>
<?php endif; ?>

Widok nie ma pojęcia, skąd biorą się dane – dostaje tylko zmienną $posts, która jest już tablicą z odpowiednią strukturą. Jeśli będzie trzeba zmienić sposób wyświetlania tytułu posta (np. dodać datę), modyfikacja dotknie jedynie tego widoku.

Kontroler jako koordynator żądania

Kontroler odpowiada za pełny cykl obsługi żądania HTTP: pobiera parametry z $_GET lub $_POST, korzysta z modelu, decyduje, jaki widok wyświetlić, przekazuje mu dane i zwraca odpowiedź. Kontroler nie powinien renderować HTML bezpośrednio przez echo, lecz używać mechanizmu ładowania szablonów.

Przykładowy kontroler dla naszego modelu Post:

<?php
// app/Controllers/PostController.php
class PostController
{
    protected Post $postModel;

    public function __construct(Post $postModel)
    {
        $this->postModel = $postModel;
    }

    public function index(): void
    {
        $posts = $this->postModel->findAll();
        $this->render('post/index', ['posts' => $posts]);
    }

    public function show(): void
    {
        $id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
        $post = $this->postModel->find($id);

        if (!$post) {
            http_response_code(404);
            echo 'Post nie został znaleziony';
            return;
        }

        $this->render('post/show', ['post' => $post]);
    }

    protected function render(string $view, array $params = []): void
    {
        extract($params);
        require __DIR__ . '/../Views/' . $view . '.php';
    }
}

Metody index i show to tzw. akcje kontrolera, obsługujące konkretne żądania: lista postów i szczegóły pojedynczego posta. Cała reszta (np. jak dokładnie wygląda HTML, gdzie jest baza danych) jest ukryta w modelu i widoku.

Przepływ żądania HTTP w architekturze MVC

Pełen cykl życia jednego żądania można opisać w kilku krokach:

  1. Użytkownik wpisuje w przeglądarce adres, np. https://example.com/post/5.
  2. Serwer HTTP kieruje żądanie do pliku public/index.php, który pełni rolę „front controllera”.
  3. Router analizuje ścieżkę URL (np. /post/5) i decyduje, że trzeba wywołać PostController@show z parametrem id=5.
  4. Kontroler tworzy obiekt modelu Post, wywołuje metodę find(5) i pobiera dane z bazy.
  5. Kontroler tworzy odpowiedź HTML, wybierając widok post/show.php i przekazując mu dane posta.
  6. Widok generuje finalny HTML, który wraca do przeglądarki użytkownika.

W ujęciu tekstowym przepływ wygląda więc: użytkownik → index.php → router → kontroler → model → widok → HTML. Każdy element ma jasno zdefiniowaną odpowiedzialność, przez co łatwiej znaleźć źródło problemu lub miejsce, które trzeba zmienić.

Programista piszący na laptopie z naklejkami, pracujący zdalnie
Źródło: Pexels | Autor: Anna Shvets

Przygotowanie środowiska i struktura katalogów pod MVC w PHP

Środowisko lokalne – minimalne wymagania

Do zbudowania własnej, prostej aplikacji MVC w PHP wystarczy lokalne środowisko z serwerem HTTP, interpreterem PHP i bazą danych. Najpopularniejsze rozwiązania to:

  • XAMPP – łatwy w instalacji pakiet zawierający Apache, MySQL i PHP; dobry na początek.
  • Laragon – lekki zestaw z Apache/Nginx i MySQL/MariaDB; wygodny dla Windows.
  • Docker – elastyczne kontenery, dobre gdy pracuje się w zespole lub na kilku projektach jednocześnie.

Wersja PHP powinna być nowsza niż 7.x (najlepiej 8.x), aby korzystać z nowszych funkcji języka i lepszej wydajności. Na środowisku deweloperskim trzeba zadbać o włączone wyświetlanie błędów:

// config/config.php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);

Na środowisku produkcyjnym te ustawienia powinny być wyłączone, a błędy logowane do pliku, aby nie ujawniać szczegółów działania aplikacji użytkownikom.

Prosta i czytelna struktura katalogów

Dla początkujących webmasterów dobrze sprawdza się prosta struktura katalogów, zbliżona do tej stosowanej w popularnych frameworkach:

projekt-mvc/
├── app/
│   ├── Controllers/
│   ├── Models/
│   └── Views/
├── config/
│   └── config.php
├── public/
│   ├── index.php
│   └── .htaccess
├── vendor/   (Composer, opcjonalnie)
└── composer.json (opcjonalnie)

Kilka kluczowych decyzji organizacyjnych:

Przy planowaniu własnego stosu narzędzi czy zestawu dobrych praktyk dla webmasteringu przydają się również zewnętrzne źródła, takie jak praktyczne wskazówki: technologia, które pomagają spojrzeć szerzej na organizację pracy, nie tylko od strony samego PHP.

  • /public – jedyny katalog dostępny publicznie z poziomu przeglądarki; tu trafia index.php, pliki CSS, JS, obrazki.
  • /app – cała logika aplikacji: modele, kontrolery, widoki.
  • /config – ustawienia bazy danych, trybu debug, ewentualne klucze API.
  • /vendor – zewnętrzne biblioteki instalowane przez Composer, jeśli z nich korzystasz.

Publiczny front controller i prosty bootstrap aplikacji

Centralnym punktem startowym aplikacji MVC jest plik public/index.php. Tam łączą się konfiguracja, autoloading klas, połączenie z bazą i uruchomienie routera. Dzięki temu reszta struktury może być trzymana poza katalogiem publicznym.

Minimalny przykład front controllera:

<?php
// public/index.php

require __DIR__ . '/../config/config.php';
require __DIR__ . '/../vendor/autoload.php'; // jeśli używasz Composera

// proste autoloading, jeśli nie ma Composera
spl_autoload_register(function (string $class) {
    $baseDir = __DIR__ . '/../app/';

    $paths = [
        'Controllers/' . $class . '.php',
        'Models/' . $class . '.php',
    ];

    foreach ($paths as $path) {
        $fullPath = $baseDir . $path;
        if (file_exists($fullPath)) {
            require $fullPath;
            return;
        }
    }
});

// połączenie z bazą danych (PDO)
$dsn = 'mysql:host=localhost;dbname=blog;charset=utf8mb4';
$user = 'root';
$pass = '';

try {
    $pdo = new PDO($dsn, $user, $pass, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
} catch (PDOException $e) {
    // na produkcji log do pliku, a użytkownik dostaje ogólny komunikat
    die('Błąd połączenia z bazą danych.');
}

// utworzenie i uruchomienie routera
$router = new Router($pdo);
$router->dispatch();

Ten prosty bootstrap robi kilka kluczowych rzeczy: ładuje konfigurację, ustawia autoloading, tworzy połączenie z bazą i przekazuje je do routera. Dzięki temu router i kontrolery nie muszą same tworzyć PDO, mogą po prostu otrzymać je jako zależność.

Plik .htaccess – przekierowanie całego ruchu do index.php

Aby wszystkie żądania trafiały do front controllera, przyda się prosty plik .htaccess w katalogu public/. To rozwiązanie typowe dla Apache. Dzięki niemu adresy mogą być ładne (np. /post/5 zamiast /index.php?route=post/show&id=5).

# public/.htaccess
Options -MultiViews

RewriteEngine On

# jeśli żądany plik fizycznie istnieje, to go serwujemy (np. CSS, JS, obrazki)
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]

# wszystko inne kierujemy do index.php
RewriteRule ^ index.php [L]

Po takim przekierowaniu cała logika rozpoznawania adresu URL przechodzi na router w PHP, co daje pełną kontrolę nad tym, jak mapować adresy na kontrolery i akcje.

Router i obsługa żądań – proste podejście krok po kroku

Najprostsza implementacja routera oparta na query string

Na początek wystarczy bardzo prosty router, oparty na parametrze route w adresie, np. /?route=post/index albo /?route=post/show&id=5. To mniej eleganckie niż „ładne” adresy, ale idealne, żeby zrozumieć mechanizm.

Przykładowa klasa Router w wersji podstawowej:

<?php
// app/Router.php
class Router
{
    protected PDO $db;

    public function __construct(PDO $db)
    {
        $this->db = $db;
    }

    public function dispatch(): void
    {
        $route = isset($_GET['route']) ? $_GET['route'] : 'post/index';

        // oczekiwany format: kontroler/akcja
        [$controllerName, $action] = explode('/', $route) + [null, null];

        if (!$controllerName || !$action) {
            http_response_code(400);
            echo 'Nieprawidłowy format trasy.';
            return;
        }

        $controllerClass = ucfirst($controllerName) . 'Controller';
        $controllerFile  = __DIR__ . '/Controllers/' . $controllerClass . '.php';

        if (!file_exists($controllerFile)) {
            http_response_code(404);
            echo 'Kontroler nie istnieje.';
            return;
        }

        require_once $controllerFile;

        if (!class_exists($controllerClass)) {
            http_response_code(500);
            echo 'Klasa kontrolera nie została znaleziona.';
            return;
        }

        // stworzenie modelu i kontrolera - bardzo proste powiązanie
        switch ($controllerClass) {
            case 'PostController':
                require_once __DIR__ . '/Models/Post.php';