Projekt końcowy cz. 1 — baza danych i rejestracja
🎯 Po tej lekcji postawisz szkielet aplikacji z bazą danych, klasą połączenia PDO i działającą rejestracją użytkownika z bezpiecznym hashowaniem hasła.
Czas połączyć wszystko w jedną spójną aplikację. Przez następne dwie lekcje zbudujesz Moje Zadania — aplikację z kontami użytkowników, logowaniem i listą zadań. Każdy użytkownik widzi tylko swoje zadania.
Ta lekcja to fundament: baza danych i rejestracja. Lekcja 6 doda logowanie, CRUD i pełne zabezpieczenia.
Struktura katalogów
moje-zadania/
├── public/
│ ├── index.php ← przekierowanie na /zadania lub /logowanie
│ ├── rejestracja.php
│ ├── logowanie.php
│ ├── wylogowanie.php
│ ├── zadania.php ← lista zadań (strona chroniona)
│ └── zadanie-akcja.php ← dodaj/usuń/ukończ (POST only)
├── src/
│ ├── Db.php ← klasa połączenia PDO
│ └── UzytkownikRepozytorium.php ← dostęp do tabeli users
├── .env ← sekrety (w .gitignore)
├── .env.example ← szablon (commituj)
└── .gitignore
Krok 1 — schemat bazy danych
Utwórz bazę i dwie tabele w MySQL:
-- Utwórz bazę danych
CREATE DATABASE IF NOT EXISTS zadania_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE zadania_db;
-- Tabela użytkowników
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
login VARCHAR(60) NOT NULL UNIQUE,
haslo_hash VARCHAR(255) NOT NULL, -- bcrypt = 60 znaków; argon2id = 96+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tabela zadań — powiązana z users przez klucz obcy
CREATE TABLE zadania (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
tytul VARCHAR(200) NOT NULL,
ukonczone TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
ON DELETE CASCADE — gdy usuniesz użytkownika, jego zadania zostaną automatycznie usunięte.
Krok 2 — sekrety w .env
# .env — NIGDY nie commituj!
DB_HOST=localhost
DB_NAME=zadania_db
DB_USER=root
DB_PASS=TwojeHasloDoMySql
# .env.example — commituj jako szablon
DB_HOST=localhost
DB_NAME=zadania_db
DB_USER=
DB_PASS=
# .gitignore
.env
Krok 3 — klasa Db (połączenie PDO)
<?php
// src/Db.php
declare(strict_types=1);
final class Db
{
private static ?PDO $instance = null;
public static function polaczenie(): PDO
{
if (self::$instance === null) {
$envSciezka = dirname(__DIR__) . '/.env';
if (!file_exists($envSciezka)) {
throw new RuntimeException('Brak pliku .env');
}
$config = parse_ini_file($envSciezka);
if ($config === false) {
throw new RuntimeException('Błąd odczytu .env');
}
self::$instance = new PDO(
dsn: 'mysql:host=' . $config['DB_HOST']
. ';dbname=' . $config['DB_NAME']
. ';charset=utf8mb4',
username: $config['DB_USER'],
password: $config['DB_PASS'],
options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
);
}
return self::$instance;
}
// Blokuj klonowanie i tworzenie instancji
private function __construct() {}
private function __clone() {}
}
Wzorzec Singleton — połączenie PDO tworzone jest raz i reużywane przez cały czas życia żądania.
Krok 4 — klasa UzytkownikRepozytorium
To ten sam wzorzec repozytorium, który budowałeś w Module 8 (lekcja „Repozytorium" — ZadanieRepozytorium), teraz zastosowany dla kont użytkowników. W lekcji 6 dołączy ZadanieRepozytorium i całość stanie się kompletną aplikacją.
<?php
// src/UzytkownikRepozytorium.php
declare(strict_types=1);
final class UzytkownikRepozytorium
{
public function __construct(
private readonly PDO $pdo,
) {}
/**
* Rejestruje nowego użytkownika. Zwraca id lub null gdy login zajęty.
*/
public function zarejestruj(string $login, string $haslo): ?int
{
// Sprawdź czy login już istnieje
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM users WHERE login = :login'
);
$stmt->execute([':login' => $login]);
if ((int) $stmt->fetchColumn() > 0) {
return null; // Login zajęty
}
// Hashuj hasło — ZAWSZE przez password_hash, NIGDY md5/sha1
$hashHasla = password_hash($haslo, PASSWORD_DEFAULT);
$stmt = $this->pdo->prepare(
'INSERT INTO users (login, haslo_hash) VALUES (:login, :hash)'
);
$stmt->execute([':login' => $login, ':hash' => $hashHasla]);
return (int) $this->pdo->lastInsertId();
}
/**
* Weryfikuje login i hasło. Zwraca tablicę użytkownika lub null.
*
* @return array<string,mixed>|null
*/
public function weryfikuj(string $login, string $haslo): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, login, haslo_hash FROM users WHERE login = :login'
);
$stmt->execute([':login' => $login]);
$user = $stmt->fetch();
if ($user === false) {
return null; // Login nie istnieje — nie mów o tym użytkownikowi
}
if (!password_verify($haslo, $user['haslo_hash'])) {
return null; // Złe hasło
}
return $user;
}
}
Krok 5 — formularz rejestracji
<?php
// public/rejestracja.php
declare(strict_types=1);
session_start();
// Jeśli już zalogowany — idź do zadań
if (isset($_SESSION['user_id'])) {
header('Location: /zadania.php');
exit;
}
require dirname(__DIR__) . '/src/Db.php';
require dirname(__DIR__) . '/src/UzytkownikRepozytorium.php';
$bledy = [];
$sukces = false;
$login = '';
// Generuj token CSRF przy pierwszym dostępie do strony
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// ✅ Weryfikacja tokenu CSRF — pierwsze co robimy w każdym handlerze POST
$tokenZFormularza = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'], $tokenZFormularza)) {
http_response_code(403);
exit('Nieprawidłowe żądanie (błąd CSRF).');
}
$login = trim($_POST['login'] ?? '');
$haslo = $_POST['haslo'] ?? '';
$haslo2 = $_POST['haslo2'] ?? '';
// Walidacja
if (strlen($login) < 3) {
$bledy[] = 'Login musi mieć co najmniej 3 znaki.';
}
if (strlen($haslo) < 8) {
$bledy[] = 'Hasło musi mieć co najmniej 8 znaków.';
}
if ($haslo !== $haslo2) {
$bledy[] = 'Hasła nie są identyczne.';
}
if ($bledy === []) {
$pdo = Db::polaczenie();
$repo = new UzytkownikRepozytorium($pdo);
$id = $repo->zarejestruj($login, $haslo);
if ($id === null) {
$bledy[] = 'Ten login jest już zajęty.';
} else {
$sukces = true;
}
}
}
?>
<!DOCTYPE html>
<html lang="pl">
<head><meta charset="UTF-8"><title>Rejestracja — Moje Zadania</title></head>
<body>
<h1>Rejestracja</h1>
<?php if ($sukces): ?>
<p style="color:green">Konto utworzone! <a href="/logowanie.php">Zaloguj się</a></p>
<?php else: ?>
<?php if ($bledy !== []): ?>
<ul style="color:red">
<?php foreach ($bledy as $blad): ?>
<li><?= htmlspecialchars($blad, ENT_QUOTES, 'UTF-8') ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="post" action="">
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
<p>
<label>Login (min. 3 znaki):<br>
<input type="text" name="login"
value="<?= htmlspecialchars($login, ENT_QUOTES, 'UTF-8') ?>">
</label>
</p>
<p>
<label>Hasło (min. 8 znaków):<br>
<input type="password" name="haslo">
</label>
</p>
<p>
<label>Powtórz hasło:<br>
<input type="password" name="haslo2">
</label>
</p>
<button type="submit">Zarejestruj się</button>
</form>
<p><a href="/logowanie.php">Masz konto? Zaloguj się</a></p>
<?php endif; ?>
</body>
</html>
type="password" nie wyświetla wartości — nie wstawiamy jej w atrybucie value. Hasła nigdy nie trafiają z powrotem do HTML (tylko do bazy przez hash). Jeśli walidacja się nie powiedzie, pole hasła jest puste — użytkownik wpisuje je od nowa.Jak to przetestować
- Uruchom serwer:
php -S localhost:8000 -t public - Otwórz
http://localhost:8000/rejestracja.php - Zarejestruj użytkownika
- Sprawdź bazę — w tabeli
userspowinien pojawić się rekord z loginem i hashem bcrypt (zaczynającym się od$2y$)
SELECT id, login, LEFT(haslo_hash, 10) AS hash_prefix FROM users;
-- id | login | hash_prefix
-- 1 | jan | $2y$12$UJ7...
Jeśli widzisz hash zaczynający się od $2y$ (bcrypt) lub $argon2id$ — wszystko działa poprawnie. Nigdy nie powinieneś widzieć oryginalnego hasła.
W kolejnej lekcji dodamy logowanie, ochronę tras, zarządzanie zadaniami i CSRF.
Funkcje w tej lekcji
Sprawdź się
Dlaczego kolumna haslo_hash w tabeli users powinna mieć typ VARCHAR(255), a nie VARCHAR(32)?
Hash bcrypt generowany przez password_hash ma 60 znaków. Hash argon2id może mieć 96 znaków lub więcej. VARCHAR(32) (rozmiar hasha md5) obciąłoby wynik i password_verify() zwróciłoby zawsze false — konta byłyby zablokowane.
Dlaczego tabela zadania ma kolumnę user_id zamiast przechowywać login użytkownika?
Relacja przez klucz obcy (FOREIGN KEY user_id REFERENCES users(id)) gwarantuje, że zadanie zawsze należy do istniejącego użytkownika. Przy usunięciu użytkownika można skaskadować usunięcie jego zadań. JOIN po liczbie całkowitej jest też szybszy niż porównywanie tekstów.
Po co sprawdzamy przed rejestracją, czy login już istnieje w bazie?
Kolumna login ma ograniczenie UNIQUE — baza odrzuci duplikat błędem. Możemy to obsłużyć przez try/catch, ale sprawdzenie wcześniej (SELECT COUNT(*)) daje lepszy UX: wyświetlamy 'Login zajęty' zamiast technicznego błędu bazy.
Ćwiczenie
Na podstawie kodu z tej lekcji: (1) utwórz tabelę users i zadania w swojej bazie MySQL, (2) utwórz plik .env z danymi połączenia, (3) zaimplementuj klasę Db i zarejestruj testowego użytkownika. Sprawdź przez phpMyAdmin lub CLI czy rekord pojawił się w tabeli users z zahashowanym hasłem (nie jako zwykły tekst).
Pokaż rozwiązanie
-- SQL do utworzenia tabel:
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
login VARCHAR(60) NOT NULL UNIQUE,
haslo_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE zadania (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
tytul VARCHAR(200) NOT NULL,
ukonczone TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
<?php
// Weryfikacja rejestracji:
declare(strict_types=1);
require 'src/Db.php';
$pdo = Db::polaczenie();
$stmt = $pdo->query('SELECT id, login, haslo_hash FROM users');
foreach ($stmt->fetchAll() as $row) {
echo $row['login'] . ': ' . substr($row['haslo_hash'], 0, 7) . '...' . PHP_EOL;
// Powinno wypisać np.: jan: $2y$12$...
}