PHP: полный мануал с примерами, функциями и ООП
Практический мануал PHP: от переменных и читаемого кода до безопасных форм, интеграций, базы данных и ООП на примере магазина. Учебный маршрут, поиск функций, подробные примеры, разбор ошибок и исходники для скачивания.

Дерево руководства
Глава 55 · От основ к архитектуре
Сквозной проект: заказ магазина от входа до оплаты
Собираем модель, интерфейс шлюза и сценарий. Проверяем отказ, повторный вызов и неизменность состояния, затем намечаем настоящую транзакционную границу.
Собрать первый полезный результат: сумму позиции
Покупатель берёт две пачки чая по 259 рублей. Магазин должен получить 51800 копеек из цены каталога и количества, затем передать эту сумму сценарию оплаты. Сначала решим только первую часть.
Что получится. Line::totalKopecks вернёт 51800; затем тот же заказ пройдёт успешную оплату, повтор и отказ.
- Каталог задаёт цену 25900 копеек
- Проверенное количество равно 2
- Позиция умножает цену на количество
- Сумма 51800 передаётся заказу
Конструктор, интерфейс, статус и readonly уже разобраны отдельно.
<?php
declare(strict_types=1);
class Line
{
public function __construct(private int $priceKopecks, private int $quantity)
{
if ($priceKopecks < 0 || $quantity < 1 || $quantity > 100) {
throw new InvalidArgumentException('Проверьте цену и количество');
}
}
public function totalKopecks(): int { return $this->priceKopecks * $this->quantity; }
}
$tea = new Line(25900, 2);
echo 'Две пачки чая: ', $tea->totalKopecks(), " коп.\n";
Ожидаемый результат
Две пачки чая: 51800 коп.
Цена и количество — разные входы с разными единицами. Конструктор задаёт учебный диапазон количества 1–100. Этот маленький объект не читает HTTP и не списывает деньги; его результат легко проверить до сборки полного сценария.
Разделить обещания участников до написания полной программы
Результат шлюза определяет следующее состояние заказа
Нажатие «Оплатить» ещё не означает paid. Сначала сценарий проверяет заказ, затем разбирает результат внешней операции.
- 1Order A-7: new, сумма 15000↓
Checkout получает объект заказа и шлюз. Уже оплаченный заказ обрабатывается отдельной веткой.
- 2charge передаёт ключ и сумму
Для нового заказа вызывается Gateway. В исполняемом опыте это локальный fake.
- accepted=true → paid
markPaid сохраняет ID операции и меняет состояние заказа.
- Явный отказ → остаётся new
Сценарий возвращает declined, не изображая успешную оплату.
- Технический сбой → нужна проверка
paid не ставится. Отсутствие отметки не доказывает, что внешний сервис ничего не списал.
- 3Повтор для того же paid-объекта
Возвращается already_paid; число вызовов fake остаётся 1.
Развилка объединяет отдельные локальные опыты главы. Защита в памяти не согласует два HTTP-процесса: реальному сервису нужны сохранённая попытка, идемпотентность провайдера и сверка неизвестного исхода.
| Участник | Что знает | Что делает |
|---|---|---|
| Line | Цена и количество | Считает сумму позиции |
| Order | Номер, сумма, статус, операция оплаты | Проверяет допустимый переход в paid |
| Gateway | Договор платёжного исполнителя | Возвращает подтверждение либо отказ; технический сбой бросает исключение |
| Checkout | Заказ и переданный Gateway | Проверяет повтор, вызывает исполнителя, применяет подтверждённый результат |
Оформление не наследует шлюз: оно использует его. В нашем запуске DemoGateway печатает учебный запрос и возвращает заранее выбранный результат. Деньги не списываются. Реальный адаптер обязан отдельно проверить статус операции провайдера, сумму, валюту и связь с заказом.
Сначала научить заказ хранить подтверждённый статус
<?php
declare(strict_types=1);
enum OrderStatus: string { case New = 'new'; case Paid = 'paid'; }
final class Order
{
private OrderStatus $status = OrderStatus::New;
private ?string $paymentId = null;
public function __construct(public readonly string $id, public readonly int $kopecks)
{
if ($id === '' || $kopecks < 1) { throw new InvalidArgumentException('Некорректный заказ'); }
}
public function isPaid(): bool { return $this->status === OrderStatus::Paid; }
public function markPaid(string $paymentId): void
{
if ($this->isPaid() || $paymentId === '') { throw new DomainException('Недопустимое подтверждение'); }
$this->paymentId = $paymentId;
$this->status = OrderStatus::Paid;
}
public function label(): string
{
return 'Заказ ' . $this->id . ': ' . ($this->isPaid() ? 'оплачен' : 'ожидает оплаты');
}
}
$order = new Order('A-42', 51800);
echo $order->label(), "\n";
$order->markPaid('demo-confirmed-42');
echo $order->label(), "\n";
Ожидаемый результат
Заказ A-42: ожидает оплаты
Заказ A-42: оплачен
Сначала известны поля и переход. status private — внешний код не может записать paid напрямую. paymentId сохраняет идентификатор подтверждения. Метод markPaid применяется после проверки результата шлюза; одна произвольная строка из браузера не является подтверждением.
Теперь вводим PaymentResult: это данные ответа, accepted и operationId. Gateway задаёт одну операцию charge с устойчивым ключом и целой суммой копеек. Checkout последовательно соединяет эти договоры.
<?php
declare(strict_types=1);
enum OrderStatus: string { case New = 'new'; case Paid = 'paid'; }
final class Order
{
private OrderStatus $status = OrderStatus::New;
private ?string $paymentId = null;
public function __construct(public readonly string $id, public readonly int $kopecks)
{
if ($id === '' || $kopecks < 1) { throw new InvalidArgumentException('Некорректный заказ'); }
}
public function isPaid(): bool { return $this->status === OrderStatus::Paid; }
public function markPaid(string $paymentId): void
{
if ($this->isPaid() || $paymentId === '') { throw new DomainException('Недопустимое подтверждение'); }
$this->paymentId = $paymentId;
$this->status = OrderStatus::Paid;
}
public function label(): string
{
return 'Заказ ' . $this->id . ': ' . ($this->isPaid() ? 'оплачен' : 'ожидает оплаты');
}
}
final readonly class PaymentResult
{
public function __construct(public bool $accepted, public string $operationId) {}
}
interface Gateway
{
public function charge(string $key, int $kopecks): PaymentResult;
}
final class DemoGateway implements Gateway
{
public function __construct(private bool $accept) {}
public function charge(string $key, int $kopecks): PaymentResult
{
echo 'Учебный запрос оплаты: ', $key, ', ', $kopecks, " коп.\n";
return new PaymentResult($this->accept, $this->accept ? 'demo-' . $key : '');
}
}
final class Checkout
{
public function __construct(private Gateway $gateway) {}
public function pay(Order $order): string
{
if ($order->isPaid()) { return 'Повторный запрос: заказ уже оплачен'; }
$result = $this->gateway->charge('pay-' . $order->id, $order->kopecks);
if (!$result->accepted) { return 'Оплата отклонена; заказ ожидает оплаты'; }
$order->markPaid($result->operationId);
return 'Оплата подтверждена: ' . $result->operationId;
}
}
$order = new Order('A-42', 51800);
$checkout = new Checkout(new DemoGateway(true));
echo $checkout->pay($order), "\n";
echo $order->label(), "\n";
echo $checkout->pay($order), "\n";
Ожидаемый результат
Учебный запрос оплаты: pay-A-42, 51800 коп.
Оплата подтверждена: demo-pay-A-42
Заказ A-42: оплачен
Повторный запрос: заказ уже оплачен
В выводе строка учебного запроса появилась только один раз. При повторном вызове у того же объекта isPaid срабатывает до шлюза. Это проверка состояния в памяти; она не решает гонку двух процессов и не сохраняет заказ после завершения PHP.
Проследить конкретный вызов pay(A-42)
- $checkout->pay($order) получает A-42 / 51800 / new
- isPaid возвращает false, вызов продолжается
- charge получает key=pay-A-42 и 51800
- DemoGateway возвращает accepted=true и demo-pay-A-42
- markPaid сохраняет операцию и Paid в том же Order
- pay возвращает сообщение в echo
- Повторный pay читает Paid и не вызывает charge
false здесь внутренний ответ проверки, а результат для читателя — изменённый заказ и отсутствие второй оплаты. Устойчивый ключ нужно передавать провайдеру, который действительно поддерживает идемпотентность; одно красивое имя ключа ничего не гарантирует.
Отказ оплаты и технический сбой — разные результаты
<?php
declare(strict_types=1);
enum OrderStatus: string { case New = 'new'; case Paid = 'paid'; }
final class Order
{
private OrderStatus $status = OrderStatus::New;
private ?string $paymentId = null;
public function __construct(public readonly string $id, public readonly int $kopecks)
{
if ($id === '' || $kopecks < 1) { throw new InvalidArgumentException('Некорректный заказ'); }
}
public function isPaid(): bool { return $this->status === OrderStatus::Paid; }
public function markPaid(string $paymentId): void
{
if ($this->isPaid() || $paymentId === '') { throw new DomainException('Недопустимое подтверждение'); }
$this->paymentId = $paymentId;
$this->status = OrderStatus::Paid;
}
public function label(): string
{
return 'Заказ ' . $this->id . ': ' . ($this->isPaid() ? 'оплачен' : 'ожидает оплаты');
}
}
final readonly class PaymentResult
{
public function __construct(public bool $accepted, public string $operationId) {}
}
interface Gateway
{
public function charge(string $key, int $kopecks): PaymentResult;
}
final class DemoGateway implements Gateway
{
public function __construct(private bool $accept) {}
public function charge(string $key, int $kopecks): PaymentResult
{
echo 'Учебный запрос оплаты: ', $key, ', ', $kopecks, " коп.\n";
return new PaymentResult($this->accept, $this->accept ? 'demo-' . $key : '');
}
}
final class Checkout
{
public function __construct(private Gateway $gateway) {}
public function pay(Order $order): string
{
if ($order->isPaid()) { return 'Повторный запрос: заказ уже оплачен'; }
$result = $this->gateway->charge('pay-' . $order->id, $order->kopecks);
if (!$result->accepted) { return 'Оплата отклонена; заказ ожидает оплаты'; }
$order->markPaid($result->operationId);
return 'Оплата подтверждена: ' . $result->operationId;
}
}
$order = new Order('A-43', 51800);
echo (new Checkout(new DemoGateway(false)))->pay($order), "\n";
echo $order->label(), "\n";
Ожидаемый результат
Учебный запрос оплаты: pay-A-43, 51800 коп.
Оплата отклонена; заказ ожидает оплаты
Заказ A-43: ожидает оплаты
Отказ — ожидаемый ответ исполнителя. Состояние не изменено, поэтому пользователь может выбрать другой разрешённый способ. Не подтверждаем оплату только потому, что метод charge вернул управление.
<?php
declare(strict_types=1);
enum OrderStatus: string { case New = 'new'; case Paid = 'paid'; }
final class Order
{
private OrderStatus $status = OrderStatus::New;
private ?string $paymentId = null;
public function __construct(public readonly string $id, public readonly int $kopecks)
{
if ($id === '' || $kopecks < 1) { throw new InvalidArgumentException('Некорректный заказ'); }
}
public function isPaid(): bool { return $this->status === OrderStatus::Paid; }
public function markPaid(string $paymentId): void
{
if ($this->isPaid() || $paymentId === '') { throw new DomainException('Недопустимое подтверждение'); }
$this->paymentId = $paymentId;
$this->status = OrderStatus::Paid;
}
public function label(): string
{
return 'Заказ ' . $this->id . ': ' . ($this->isPaid() ? 'оплачен' : 'ожидает оплаты');
}
}
final readonly class PaymentResult
{
public function __construct(public bool $accepted, public string $operationId) {}
}
interface Gateway
{
public function charge(string $key, int $kopecks): PaymentResult;
}
final class DemoGateway implements Gateway
{
public function __construct(private bool $accept) {}
public function charge(string $key, int $kopecks): PaymentResult
{
echo 'Учебный запрос оплаты: ', $key, ', ', $kopecks, " коп.\n";
return new PaymentResult($this->accept, $this->accept ? 'demo-' . $key : '');
}
}
final class Checkout
{
public function __construct(private Gateway $gateway) {}
public function pay(Order $order): string
{
if ($order->isPaid()) { return 'Повторный запрос: заказ уже оплачен'; }
$result = $this->gateway->charge('pay-' . $order->id, $order->kopecks);
if (!$result->accepted) { return 'Оплата отклонена; заказ ожидает оплаты'; }
$order->markPaid($result->operationId);
return 'Оплата подтверждена: ' . $result->operationId;
}
}
class TimeoutGateway implements Gateway
{
public function charge(string $key, int $kopecks): PaymentResult
{
throw new RuntimeException('Ответ платёжного сервиса не получен');
}
}
$order = new Order('A-44', 51800);
try { echo (new Checkout(new TimeoutGateway()))->pay($order), "\n"; }
catch (RuntimeException $error) { echo 'Нужна сверка: ', $error->getMessage(), "\n"; }
echo $order->label(), "\n";
Ожидаемый результат
Нужна сверка: Ответ платёжного сервиса не получен
Заказ A-44: ожидает оплаты
Тайм-аут не доказывает, что провайдер не списал деньги. В реальной системе требуется состояние неопределённости, запрос статуса по тому же ключу и сверка, прежде чем разрешать новое списание. Учебная модель сохраняет только New/Paid и явно обозначает это ограничение.
Цена берётся из каталога, количество проверяется на границе
<?php
declare(strict_types=1);
$catalog = ['tea' => ['title' => 'Чай', 'price_kopecks' => 25900]];
$input = ['sku' => 'tea', 'quantity' => '2', 'price_kopecks' => '1'];
$sku = $input['sku'];
$quantity = filter_var($input['quantity'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 100]]);
if (!isset($catalog[$sku]) || $quantity === false) {
throw new InvalidArgumentException('Неизвестный товар или неверное количество');
}
$product = $catalog[$sku];
$total = $product['price_kopecks'] * $quantity;
echo $product['title'], ' × ', $quantity, ': ', $total, " коп.\n";
Ожидаемый результат
Чай × 2: 51800 коп.
Здесь известная учебная структура массива; обработчик настоящей формы сначала проверяет, что sku и quantity — скалярные строки, а не массивы. Поле price_kopecks клиента намеренно игнорируется: авторитетная цена приходит из каталога. Ограничение суммы и защита от переполнения нужны для выбранных реальных диапазонов.
После оплаты уведомление должно пережить отказ доставки
Клиенту надо отправить сообщение, но сбой Telegram не должен отменять подтверждённую оплату. Запишите изменение заказа и задачу уведомления одной транзакцией в БД; отдельный worker заберёт задачу, отправит и сохранит результат. Этот приём часто называют outbox. Он требует повторяемого обработчика и уникального ключа сообщения, а не только объекта OrderPaid в памяти.
Локальная цепочка CRM → сообщение показывает разделение клиентов. Для магазина источник сообщения — подтверждённый переход заказа; место вызова транспорта и политика повторов остаются явными.
Попробуйте. Добавьте шаг выдачи чека только после успешной pay. Как отличить повтор от новой оплаты?
Разобрать решение
Верните отдельный тип результата сценария, например enum CheckoutOutcome с PaidNow/AlreadyPaid/Declined. Тогда выдачу и уведомление можно привязать к PaidNow; надёжное хранение события всё равно делайте вместе с транзакцией изменения заказа. Не разбирайте русские сообщения через str_contains.
Что ещё отделяет локальную модель от настоящего магазина
| Реальное условие | Необходимое решение |
|---|---|
| Два одновременных запроса | Транзакция, блокировка/условное обновление и уникальные ключи |
| Сбой после ответа банка до записи БД | Идемпотентный запрос и сверка операции |
| Чужой номер заказа | Проверка владельца и прав текущего пользователя |
| Повтор webhook | Проверенная подпись и уникальность события |
| Историческая цена | Снимок проверенной цены/валюты в заказе |
| Сбой уведомления | Надёжная очередь/outbox и повторяемый обработчик |
Все показанные запуски — локальные PHP-программы с точным выводом. Они не проверяют банк, БД, HTTP-авторизацию или конкуренцию процессов. Теперь эти части можно добавлять отдельно, сохраняя понятные договоры Order, Gateway и Checkout. Начальный выигрыш ООП здесь — локальность правил и заменяемый исполнитель, а не количество классов.
Источники и обсуждения
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


