Перейти к содержимому
Поиск
Php Разработка

PHP: полный мануал с примерами, функциями и ООП

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

Александр НекрасовИзменено:
Практический мануал PHP: от переменных и функций к объектной модели интернет-магазина
PHP 8.5.11149 глав4458 карточек справочника
Дерево руководства

Глава 55 · От основ к архитектуре

Сквозной проект: заказ магазина от входа до оплаты

Собираем модель, интерфейс шлюза и сценарий. Проверяем отказ, повторный вызов и неизменность состояния, затем намечаем настоящую транзакционную границу.

Собрать первый полезный результат: сумму позиции

Покупатель берёт две пачки чая по 259 рублей. Магазин должен получить 51800 копеек из цены каталога и количества, затем передать эту сумму сценарию оплаты. Сначала решим только первую часть.

Что получится. Line::totalKopecks вернёт 51800; затем тот же заказ пройдёт успешную оплату, повтор и отказ.

Как проходит конкретный вызов
  1. Каталог задаёт цену 25900 копеек
  2. Проверенное количество равно 2
  3. Позиция умножает цену на количество
  4. Сумма 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. Сначала сценарий проверяет заказ, затем разбирает результат внешней операции.

  1. Order A-7: new, сумма 15000

    Checkout получает объект заказа и шлюз. Уже оплаченный заказ обрабатывается отдельной веткой.

  2. charge передаёт ключ и сумму

    Для нового заказа вызывается 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)

От кнопки сценария к состоянию заказа
  1. $checkout->pay($order) получает A-42 / 51800 / new
  2. isPaid возвращает false, вызов продолжается
  3. charge получает key=pay-A-42 и 51800
  4. DemoGateway возвращает accepted=true и demo-pay-A-42
  5. markPaid сохраняет операцию и Paid в том же Order
  6. pay возвращает сообщение в echo
  7. Повторный 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. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.