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:
- Użytkownik wpisuje w przeglądarce adres, np.
https://example.com/post/5. - Serwer HTTP kieruje żądanie do pliku
public/index.php, który pełni rolę „front controllera”. - Router analizuje ścieżkę URL (np.
/post/5) i decyduje, że trzeba wywołaćPostController@showz parametremid=5. - Kontroler tworzy obiekt modelu
Post, wywołuje metodęfind(5)i pobiera dane z bazy. - Kontroler tworzy odpowiedź HTML, wybierając widok
post/show.phpi przekazując mu dane posta. - 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ć.

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';
