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

0
53
Rate this post

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';
                $model = new Post($this->db);
                $controller = new PostController($model);
                break;
            default:
                http_response_code(500);
                echo 'Brak konfiguracji routera dla podanego kontrolera.';
                return;
        }

        if (!method_exists($controller, $action)) {
            http_response_code(404);
            echo 'Akcja nie istnieje.';
            return;
        }

        $controller->{$action}();
    }
}

Taki router ma ograniczenia (trzeba ręcznie tworzyć kontrolery i modele w switch), ale jasno pokazuje przepływ: od parametru route, przez wybór klasy kontrolera i akcji, aż po ich wywołanie.

Prosty router z ładnymi adresami oparty na ścieżce URL

Kiedy podstawowa wersja działa, łatwo przejść na korzystanie z adresów typu /post/show/5. Wtedy zamiast query stringu, router analizuje ścieżkę z $_SERVER['REQUEST_URI'].

<?php
// app/Router.php (wersja oparta na ścieżce)
class Router
{
    protected PDO $db;

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

    public function dispatch(): void
    {
        $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        $path = trim($path, '/'); // np. "post/show/5"

        if ($path === '') {
            $path = 'post/index';
        }

        $segments = explode('/', $path);

        $controllerName = $segments[0] ?? 'post';
        $action         = $segments[1] ?? 'index';
        $params         = array_slice($segments, 2); // reszta segmentów jako parametry

        $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;
        require_once __DIR__ . '/Models/Post.php'; // uproszczenie

        $model = new Post($this->db);
        $controller = new $controllerClass($model);

        if (!method_exists($controller, $action)) {
            http_response_code(404);
            echo 'Akcja nie istnieje.';
            return;
        }

        // proste przekazywanie parametrów jako argumenty metody
        call_user_func_array([$controller, $action], $params);
    }
}

Przy takim podejściu akcja w kontrolerze może przyjąć parametr z URL, np. /post/show/5 wywoła PostController::show(5).

Dostosowanie kontrolera do routera z parametrami

Jeżeli router przekazuje ID jako argument, akcję w kontrolerze można uprościć i nie korzystać bezpośrednio z $_GET. To zbliża kod do tego, jak działają dojrzałe frameworki.

Dobrym uzupełnieniem będzie też materiał: Jak skutecznie zarządzać czasem w projektach IT — warto go przejrzeć w kontekście powyższych wskazówek.

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

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

    public function show(int $id = 0): void
    {
        if ($id <= 0) {
            http_response_code(400);
            echo 'Nieprawidłowy identyfikator posta.';
            return;
        }

        $post = $this->postModel->find($id);

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

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

Taki wzorzec ma prostą zaletę: parametry z URL są jawnie zadeklarowane w sygnaturze metody, dzięki czemu kod jest czytelniejszy i łatwiej go testować.

Obsługa różnych metod HTTP w routerze

W miarę rozwoju projektu pojawia się potrzeba odróżnienia akcji dla GET i POST. Można to wprowadzić nawet w prostym routerze, dodając warstwę rozpoznawania metody HTTP.

<?php
// prosty przykład mapowania metod HTTP
$routes = [
    'GET' => [
        'post/index' => ['PostController', 'index'],
        'post/show'  => ['PostController', 'show'],
    ],
    'POST' => [
        'post/store' => ['PostController', 'store'],
    ],
];

$method = $_SERVER['REQUEST_METHOD'];
$routeKey = $controllerName . '/' . $action;

if (!isset($routes[$method][$routeKey])) {
    http_response_code(405);
    echo 'Metoda HTTP nie jest obsługiwana dla tej trasy.';
    exit;
}

To nadal bardzo prosta mapa, ale już wprowadza ważną regułę: „lista postów” to GET /post/index, a „zapis posta” to POST /post/store. Unika się wtedy łączenia kilku rodzajów logiki w jednej akcji.

Zbliżenie ekranu monitora z kodem PHP podczas programowania
Źródło: Pexels | Autor: Pixabay

Model i dostęp do bazy danych – pierwsza warstwa danych

Tworzenie tabeli i konfiguracja bazy pod model

Model operuje na konkretnej tabeli w bazie danych. Dobrze, jeśli struktura tabeli jest prosta i dopasowana do potrzeb aplikacji. Dla wspomnianego modelu Post przykładowa tabela w MySQL może wyglądać tak:

CREATE TABLE posts (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Jeżeli środowisko lokalne jest już gotowe, taką tabelę można utworzyć przez phpMyAdmin, Adminer lub bezpośrednio z linii poleceń MySQL.

Rozszerzenie modelu o aktualizację i usuwanie danych

Model staje się pełny dopiero wtedy, gdy obsługuje cały zestaw operacji CRUD. Wspomniany wcześniej przykład zawierał metody findAll, find i create. Można dopisać do niego także aktualizację i usuwanie.

<?php
// app/Models/Post.php (dalsze metody)
class Post
{
    // ...

    public function update(int $id, array $data): bool
    {
        if ($id <= 0) {
            return false;
        }

        if (empty($data['title']) || empty($data['content'])) {
            return false;
        }

        $stmt = $this->db->prepare(
            'UPDATE posts SET title = :title, content = :content WHERE id = :id'
        );

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

    public function delete(int $id): bool
    {
        if ($id <= 0) {
            return false;
        }

        $stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
        return $stmt->execute(['id' => $id]);
    }
}

W tym miejscu model zaczyna faktycznie reprezentować zasób „post” w aplikacji. Kontroler nie musi się zastanawiać nad SQL-em, tylko wywołuje odpowiednią metodę.

Podstawowa walidacja na poziomie modelu

Najprostsza walidacja – czy pola nie są puste – może być załatwiona w modelu, jak w przykładach powyżej. W małych projektach to wystarczające, jeśli walidacja jest spójna i nie jest rozproszona po kontrolerach.

Jeśli pojawiają się bardziej złożone zasady (np. unikalny tytuł, minimalna liczba znaków), lepiej wydzielić walidację do osobnej metody, którą da się przetestować w oderwaniu od bazy danych.

<?php
// app/Models/Post.php (fragment z walidacją)
class Post
{
    // ...

    public function validate(array $data): array
    {
        $errors = [];

        if (empty($data['title'])) {
            $errors['title'] = 'Tytuł jest wymagany.';
        } elseif (mb_strlen($data['title']) < 3) {
            $errors['title'] = 'Tytuł musi mieć co najmniej 3 znaki.';
        }

        if (empty($data['content'])) {
            $errors['content'] = 'Treść jest wymagana.';
        }

        return $errors;
    }

    public function create(array $data): bool
    {
        $errors = $this->validate($data);
        if (!empty($errors)) {
            // w prostym wariancie walidację można obsłużyć w kontrolerze,
            // np. przekazując błędy do widoku
            return false;
        }

        // ... zapis do bazy jak wcześniej
    }
}

Dodatkowym krokiem jest przekazanie informacji o błędach z modelu do kontrolera, a następnie do widoku. W pierwszych projektach można to zrealizować przez prosty mechanizm, np. ustawienie właściwości $lastErrors w modelu i odczytanie jej przez kontroler.

Bezpieczne korzystanie z PDO i przygotowanych zapytań

W kontekście początkujących webmasterów jedna kwestia ma szczególne znaczenie: unikanie składania zapytań SQL z „gołych” danych użytkownika. PDO z przygotowanymi zapytaniami już rozwiązuje większość problemów z SQL Injection, jeśli parametry są zawsze przekazywane jako bindowane wartości.

Prosty przykład, czego unikać:

// ZŁE podejście - nie składaj tak zapytań
$id = $_GET['id'];
$sql = "SELECT * FROM posts WHERE id = $id"; // zmienna wklejona wprost do SQL
$stmt = $pdo->query($sql);

Bezpieczniejszy wariant z przygotowanym zapytaniem i rzutowaniem typu:

// DOBRE podejście
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;

$stmt = $pdo->prepare('SELECT * FROM posts WHERE id = :id');
$stmt->execute(['id' => $id]);
$post = $stmt->fetch();

W modelu takie podejście jest już zastosowane. Jeżeli każda metoda modelu działa w ten sposób, ryzyko wstrzyknięć SQL znacząco maleje.

Wyodrębnienie konfiguracji bazy do osobnego pliku

Łączenie się z bazą bezpośrednio w kodzie routera albo kontrolera szybko robi bałagan. Dane dostępowe do bazy, nazwa hosta, kodowanie – wszystko to powinno leżeć w jednym miejscu. Dzięki temu przy zmianie hasła czy serwera nie trzeba przeszukiwać całego projektu.

Przykładowy plik z konfiguracją:

<?php
// config/database.php
return [
    'dsn'      => 'mysql:host=localhost;dbname=mvc_blog;charset=utf8mb4',
    'user'     => 'mvc_user',
    'password' => 'super_tajne_haslo',
    'options'  => [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ],
];

Plik startowy aplikacji (np. public/index.php) może wczytać tę konfigurację i utworzyć współdzielone połączenie PDO:

<?php
// public/index.php
$config = require __DIR__ . '/../config/database.php';

try {
    $pdo = new PDO(
        $config['dsn'],
        $config['user'],
        $config['password'],
        $config['options']
    );
} catch (PDOException $e) {
    http_response_code(500);
    echo 'Błąd połączenia z bazą danych.';
    // w środowisku deweloperskim można wypisać szczegóły:
    // echo '<pre>' . $e->getMessage() . '</pre>';
    exit;
}

// teraz $pdo można przekazać do routera i dalej do modeli
$router = new Router($pdo);
$router->dispatch();

Dane dostępowe (login, hasło) nie powinny trafić do repozytorium publicznego. Przy prostych projektach pomaga .gitignore i lokalny plik konfiguracyjny; przy bardziej rozbudowanych – zmienne środowiskowe.

Wspólny model bazowy jako fundament kolejnych klas

Gdy w projekcie pojawia się drugi, trzeci model, kopiowanie tego samego kodu tworzenia zapytań, metod find czy findAll staje się męczące. Rozwiązaniem jest prosty model bazowy, po którym dziedziczą konkretne klasy.

<?php
// app/Models/BaseModel.php
abstract class BaseModel
{
    protected PDO $db;
    protected string $table;

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

    public function find(int $id): ?array
    {
        if ($id <= 0) {
            return null;
        }

        $stmt = $this->db->prepare(
            "SELECT * FROM {$this->table} WHERE id = :id LIMIT 1"
        );
        $stmt->execute(['id' => $id]);
        $result = $stmt->fetch();

        return $result ?: null;
    }

    public function findAll(): array
    {
        $stmt = $this->db->query("SELECT * FROM {$this->table} ORDER BY id DESC");
        return $stmt->fetchAll();
    }
}

Model Post może z tego skorzystać:

<?php
// app/Models/Post.php
class Post extends BaseModel
{
    protected string $table = 'posts';

    public function __construct(PDO $db)
    {
        parent::__construct($db);
    }

    // metody create, update, delete, validate jak wcześniej
}

Jeśli za kilka dni dojdzie np. model User, wystarczy wskazać nazwę tabeli i dopisać specyficzne metody. Podstawowe find i findAll będą już gotowe.

Widok w MVC – od danych do HTML-a

Dlaczego widok to nie echo w kontrolerze

Na pierwszych etapach kuszące jest wyświetlanie danych bezpośrednio w kontrolerze przy pomocy echo. W krótkim pliku wygląda to niewinnie, ale jeśli aplikacja rośnie, po kilku tygodniach trudno dojść, gdzie kończy się logika, a zaczyna prezentacja.

Oddzielny widok oznacza osobne pliki .php odpowiedzialne wyłącznie za generowanie HTML-a. Kontroler przekazuje im dane, a widok ich nie modyfikuje poza najprostszą pętlą czy warunkiem.

Prosty system widoków – funkcja renderująca

Najprościej zorganizować widoki jako szablony w katalogu app/Views. Pomaga w tym mała metoda render w bazowej klasie kontrolera.

<?php
// app/Controllers/BaseController.php
abstract class BaseController
{
    protected function render(string $view, array $params = []): void
    {
        extract($params, EXTR_SKIP);

        $viewFile = __DIR__ . '/../Views/' . $view . '.php';

        if (!file_exists($viewFile)) {
            http_response_code(500);
            echo 'Widok nie istnieje.';
            return;
        }

        require __DIR__ . '/../Views/layout.php';
    }
}

Plik layout.php może pełnić rolę szkieletu strony (nagłówek, stopka, podstawowy układ), a w środku wczytywać konkretny widok treści:

<?php
// app/Views/layout.php
?>
<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="utf-8">
    <title>Prosty blog MVC</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <header>
        <h1>Prosty blog MVC</h1>
        <nav>
            <a href="/post/index">Lista postów</a>
            <a href="/post/create">Dodaj post</a>
        </nav>
    </header>

    <main>
        <?php require $viewFile; ?>
    </main>

    <footer>
        &copy; <?= date('Y'); ?> Mój blog
    </footer>
</body>
</html>

Widok odpowiedzialny za listę postów przyjmuje dane z kontrolera pod postacią tablicy $posts:

<?php
// app/Views/post/index.php
?>
<h2>Lista postów</h2>

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

Kod HTML jest odseparowany od logiki pobierania danych. Kontroler skupia się na „co”, widok na „jak pokazać”.

Widok formularza i przekazywanie błędów walidacji

Dla akcji tworzenia posta przydaje się widok formularza, który potrafi pokazać poprzednio wpisane dane i komunikaty o błędach. Dobrym miejscem na to jest plik app/Views/post/create.php.

<?php
// app/Views/post/create.php
/** @var array $errors */
/** @var array $old */
?>
<h2>Dodaj nowy post</h2>

<?php if (!empty($errors)): ?>
    <div class="errors">
        <ul>
            <?php foreach ($errors as $field => $message): ?>
                <li><strong><?= htmlspecialchars($field); ?>:</strong>
                    <?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); ?>
                </li>
            <?php endforeach; ?>
        </ul>
    </div>
<?php endif; ?>

<form method="post" action="/post/store">
    <div>
        <label for="title">Tytuł</label>
        <input type="text" name="title" id="title"
               value="<?= htmlspecialchars($old['title'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
    </div>

    <div>
        <label for="content">Treść</label>
        <textarea name="content" id="content" rows="8"><?=
            htmlspecialchars($old['content'] ?? '', ENT_QUOTES, 'UTF-8');
        ?></textarea>
    </div>

    <button type="submit">Zapisz</button>
</form>

Kontroler może przekazać do widoku tablicę $errors i $old (poprzednie wartości z formularza):

<?php
// app/Controllers/PostController.php (fragment)
public function create(): void
{
    $this->render('post/create', [
        'errors' => [],
        'old'    => [],
    ]);
}

public function store(): void
{
    $data = [
        'title'   => $_POST['title']   ?? '',
        'content' => $_POST['content'] ?? '',
    ];

    $errors = $this->postModel->validate($data);

    if (!empty($errors)) {
        $this->render('post/create', [
            'errors' => $errors,
            'old'    => $data,
        ]);
        return;
    }

    if ($this->postModel->create($data)) {
        header('Location: /post/index');
        exit;
    }

    $this->render('post/create', [
        'errors' => ['general' => 'Nie udało się zapisać posta.'],
        'old'    => $data,
    ]);
}

W takiej konfiguracji użytkownik po błędzie walidacji nie musi wpisywać treści od nowa, bo widok korzysta z danych w $old.

Proste zasady bezpieczeństwa w widokach

Widok pracuje zazwyczaj na danych pochodzących z zewnątrz: z bazy, z formularzy, czasem z API. Najważniejsza zasada to filtrowanie tego, co jest wyświetlane w HTML-u, przy pomocy htmlspecialchars(). Bez tego użytkownik może wstrzyknąć kod JavaScript do strony (XSS).

Na koniec warto zerknąć również na: Case study: automatyzacja wysyłki newslettera w PHP — to dobre domknięcie tematu.

Bezpieczne wyświetlenie tytułu:

<?= htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8'); ?>

Dane, które nie trafiają do HTML-a, a np. do atrybutu href czy data-*, również powinny być odpowiednio oczyszczone lub rzutowane.

Łączenie warstw – przykład kompletnego przepływu

Scenariusz: dodanie posta od URL do bazy

Dobrym testem małego frameworka MVC jest prześledzenie pełnego przepływu żądania. Przykład: użytkownik chce dodać nowy post za pomocą formularza pod adresem /post/create i zapisać go do bazy.

  1. Przeglądarka wykonuje żądanie GET /post/create.
  2. Router parsuje URL, wybiera PostController i metodę create.
  3. PostController::create() wywołuje $this->render('post/create').
  4. System widoków ładuje layout.php i w jego wnętrzu plik post/create.php.
  5. Użytkownik wypełnia formularz i wysyła POST /post/store.
  6. Router mapuje tę trasę na PostController::store().
  7. Metoda store() zbiera dane z $_POST i przekazuje do $postModel->validate().
  8. Jeśli brak błędów, $postModel->create() zapisuje rekord do tabeli posts przez PDO.
  9. Po udanym zapisie kontroler wykonuje header('Location: /post/index') i przekierowuje użytkownika na listę postów.
  10. Router obsługuje /post/index, kontroler pobiera dane z modelu i przekazuje do widoku listy.

W tym łańcuchu widać, że każda warstwa ma ograniczoną odpowiedzialność: router rozprowadza żądania, kontroler decyduje o ścieżce wykonania, model zapisuje i czyta dane, a widok generuje HTML. Diagnostyka błędów jest też prostsza: jeśli nie ma wpisu w bazie, problemu szuka się w modelu lub zapytaniu, a nie w całym stosie naraz.

Dodanie edycji i usuwania bez łamania struktury

Gdy struktura MVC jest już ustawiona, rozbudowa funkcjonalności zwykle oznacza dodanie nowych metod w tych samych miejscach, zamiast przebudowy wszystkiego. Dla zasobu „post” naturalnym krokiem są akcje edycji i usuwania.

Router z mapowaniem tras może zostać rozszerzony o:

<?php
$routes = [
    'GET' => [
        'post/index'  => ['PostController', 'index'],
        'post/show'   => ['PostController', 'show'],
        'post/create' => ['PostController', 'create'],
        'post/edit'   => ['PostController', 'edit'],
    ],
    'POST' => [
        'post/store'  => ['PostController', 'store'],
        'post/update' => ['PostController', 'update'],
        'post/delete' => ['PostController', 'delete'],
    ],
];

Kontroler obsługuje te trasy przy pomocy nowych metod:

Najczęściej zadawane pytania (FAQ)

Co to jest MVC w PHP i po co mi to jako początkującemu webmasterowi?

MVC (Model–View–Controller) to sposób organizacji kodu, który rozdziela trzy główne „role” w aplikacji: obsługę danych (Model), prezentację (Widok) i logikę żądania (Kontroler). Zamiast jednego pliku z pomieszanym HTML, SQL i PHP, dostajesz uporządkowaną strukturę katalogów i klas.

Dla początkującego webmastera kluczowa korzyść to łatwiejsza praca z rosnącym projektem. Gdy klient dorzuca kolejne wymagania, nie szukasz wszystkiego w jednym pliku, tylko od razu wiesz, gdzie jest logika, gdzie HTML, a gdzie operacje na bazie danych.

Kiedy w PHP naprawdę warto użyć MVC, a kiedy wystarczy jeden plik?

Jeśli tworzysz jedną, prostą podstronę lub mały landing z formularzem, który nigdy nie będzie rozwijany – klasyczny „jeden plik PHP + kilka funkcji” w zupełności wystarczy. Narzut MVC w takim przypadku tylko spowolni pracę na starcie.

MVC zaczyna się opłacać, gdy projekt ma:

  • więcej niż kilka podstron o różnej logice,
  • logowanie, sesje, panel administracyjny,
  • perspektywę rozbudowy lub pracy w zespole.

Jeśli czujesz, że kod „puchnie” i każda zmiana wymaga przeszukania kilku miejsc, to dobry moment, żeby przejść na prostą strukturę MVC.

Jaka jest różnica między Modelem, Widokiem i Kontrolerem w praktycznym projekcie?

Model odpowiada za dane i reguły z nimi związane: wie, jak wyglądają tabele, jak walidować dane i jak wykonać operacje CRUD. Kontroler „gada” z modelem: pobiera dane, modyfikuje je, reaguje na dane z formularza.

Widok to szablon HTML z minimalną ilością PHP (if, foreach, echo). Nie ma w nim zapytań SQL, pracy na sesji ani zaawansowanych obliczeń; dostaje gotowe dane z kontrolera i tylko je wyświetla.

Kontroler jest pośrednikiem: odbiera żądanie HTTP, wyciąga parametry, korzysta z modeli, wybiera odpowiedni widok i przekazuje mu dane. Dzięki temu każda z tych części ma jasny zakres obowiązków.

Czy muszę używać frameworka (np. Laravel, Symfony), żeby pracować w MVC w PHP?

Nie. Można zbudować bardzo prosty, „ręczny” MVC w czystym PHP, bazując na kilku katalogach (Models, Views, Controllers) i jednym pliku index.php pełniącym rolę front controllera oraz prostym routerze na bazie parametru $_GET['route'].

Frameworki takie jak Laravel czy Symfony dają gotową infrastrukturę (routing, szablony, ORM, DI), ale na start bywają przytłaczające. Dla początkującego webmastera dobrym krokiem jest najpierw zrozumienie prostego własnego MVC, a dopiero potem przejście do cięższego frameworka.

Jak zacząć przepisywanie istniejącej strony „spaghetti” na strukturę MVC?

Najprostsza droga to stopniowa migracja. Zamiast przepisywać wszystko naraz, wybierz jeden fragment funkcjonalności, np. obsługę postów na blogu lub formularza kontaktowego, i wydziel go do osobnych plików: modelu, kontrolera i widoku.

Typowy plan może wyglądać tak:

  • przenieś zapytania SQL i walidację danych do klasy Modelu,
  • stwórz Kontroler, który woła metody modelu i ładuje odpowiednie widoki,
  • przerzuć HTML do plików widoków, zostawiając jedynie echo/foreach na danych przekazywanych z kontrolera.

Gdy ten pierwszy kawałek zacznie działać, powtarzasz schemat dla kolejnych części, aż w końcu większość logiki przestaje siedzieć w jednym wielkim pliku.

Czy MVC nie jest przesadą przy małych stronach firmowych w PHP?

Przy naprawdę małej wizytówce z dwoma podstronami – często tak. Natomiast wiele „małych stron firmowych” po kilku miesiącach dostaje blog, prosty panel dla klienta, sekcję aktualności albo galerię plików i wtedy brak struktury zaczyna boleć.

Jeśli tworzysz coś, co ma choćby szansę się rozrosnąć, prosty szkielet MVC (kilka kontrolerów, modele dla kluczowych tabel, widoki dla layoutu i podstron) bardzo ułatwia późniejsze dokładanie nowych elementów. Różnica jest szczególnie widoczna, gdy klient co tydzień zgłasza inną poprawkę.

Jak MVC pomaga, gdy nad projektem PHP pracuje kilka osób?

MVC porządkuje podział pracy. Frontendowiec lub osoba „od wyglądu” skupia się na widokach i szablonach, backendowiec na modelach i kontrolerach, a ktoś odpowiedzialny za bazę danych może pracować na poziomie modeli.

Zmniejsza to ryzyko, że ktoś przypadkowo popsuje SQL, grzebiąc w HTML, albo zepsuje layout, modyfikując logikę zapisów do bazy. Każdy wie, w której części projektu powinien działać, a konflikty w repozytorium są zwykle prostsze do rozwiązania.

Co warto zapamiętać

  • Trzymanie całej logiki w jednym pliku PHP szybko prowadzi do chaosu: trudniej znaleźć fragment odpowiedzialny za konkretną funkcję, rośnie liczba kopiowanych „wstawek” i przypadkowych błędów.
  • MVC porządkuje aplikację przez wyraźny podział ról: model odpowiada za dane i reguły z nimi związane, widok za prezentację HTML, a kontroler za obsługę żądania i łączenie wszystkiego w całość.
  • Zmiana wyglądu powinna oznaczać pracę głównie w widokach, zmiana zasad biznesowych – w modelach, a dodanie nowej funkcji (np. nowa podstrona) – dopisanie akcji w kontrolerze i nowego widoku.
  • Nawet małe projekty (strona firmowa, prosty blog) zyskują na MVC: łatwiej dodać kolejne elementy, takie jak panel logowania czy zarządzanie plikami, bez przepisywania istniejącego kodu.
  • MVC szczególnie pomaga, gdy projekt ma więcej niż kilka podstron, korzysta z logowania i sesji, posiada panel administracyjny albo jest rozwijany zespołowo i często modyfikowany.
  • Dla jednorazowego, prostego landingu lub statycznej podstrony pełne MVC bywa przesadą – pojedynczy dobrze uporządkowany plik z kilkoma funkcjami może w zupełności wystarczyć.
  • Model pełni rolę „źródła prawdy” o danych: zna strukturę tabel, waliduje dane wejściowe i udostępnia podstawowe operacje CRUD, dzięki czemu reszta aplikacji nie musi znać szczegółów bazy.