Этот пример показывает механику нормальной разработки на PHP: пишем небольшую бизнес-логику, покрываем её тестами, добавляем debug-логирование и выкатываем код на сервер только после проверки.
Подходит, когда нужно показать базовую дисциплину проекта: src/, tests/, bin/, scripts/, Composer, PHPUnit, логирование и deploy.
Что получится
- класс с расчётом итоговой цены заказа;
- валидация входных данных;
- debug-лог в формате JSON Lines;
- тесты PHPUnit;
- команды
composer test,composer demo,composer check; - пример deploy-скрипта через
rsyncи release-директории.
Структура проекта
php-quality-example/ ├── bin/ │ └── demo.php ├── scripts/ │ └── deploy.sh ├── src/ │ ├── DebugLogger.php │ └── OrderPriceCalculator.php ├── tests/ │ └── OrderPriceCalculatorTest.php ├── var/ │ └── log/ ├── composer.json └── phpunit.xml
1. composer.json
{
"name": "helpfulcode/php-quality-example",
"description": "Minimal PHP project with tests, debug and deploy example",
"type": "project",
"require": {
"php": "^8.2"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload": {
"psr-4": {
"HelpfulCode\\PhpQualityExample\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"HelpfulCode\\PhpQualityExample\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit --configuration phpunit.xml",
"demo": "php bin/demo.php",
"check": [
"@test",
"@demo"
]
},
"config": {
"sort-packages": true
}
}
Установка зависимостей
composer install
2. phpunit.xml
<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="vendor/autoload.php" colors="true" cacheDirectory=".phpunit.cache" > <testsuites> <testsuite name="unit"> <directory>tests</directory> </testsuite> </testsuites> </phpunit>
3. src/OrderPriceCalculator.php
<?php
declare(strict_types=1);
namespace HelpfulCode\PhpQualityExample;
use InvalidArgumentException;
final class OrderPriceCalculator
{
private const MaxDiscountPercent = 90;
private const MaxPriceKopecks = 100000000;
private const MaxQuantity = 999;
public function calculate(array $items, int $deliveryKopecks = 0, int $discountPercent = 0): array
{
$this->assertDiscountPercent($discountPercent);
$this->assertNonNegativeMoney($deliveryKopecks, 'deliveryKopecks');
if ($items === []) {
throw new InvalidArgumentException('Items list is empty');
}
$subtotalKopecks = 0;
foreach ($items as $itemIndex => $item) {
if (!is_array($item)) {
throw new InvalidArgumentException("Item {$itemIndex}: item must be array");
}
$subtotalKopecks += $this->calculateItemTotal($item, $itemIndex);
}
$discountKopecks = (int) floor($subtotalKopecks * ($discountPercent / 100));
$totalKopecks = $subtotalKopecks - $discountKopecks + $deliveryKopecks;
return [
'subtotalKopecks' => $subtotalKopecks,
'discountKopecks' => $discountKopecks,
'deliveryKopecks' => $deliveryKopecks,
'totalKopecks' => $totalKopecks,
'totalFormatted' => self::formatKopecks($totalKopecks),
];
}
public static function formatKopecks(int $kopecks): string
{
return number_format($kopecks / 100, 2, '.', ' ') . ' ₽';
}
private function calculateItemTotal(array $item, int $itemIndex): int
{
foreach (['priceKopecks', 'quantity'] as $fieldName) {
if (!array_key_exists($fieldName, $item)) {
throw new InvalidArgumentException("Item {$itemIndex}: {$fieldName} required");
}
if (!is_int($item[$fieldName])) {
throw new InvalidArgumentException("Item {$itemIndex}: {$fieldName} must be integer");
}
}
$this->assertNonNegativeMoney($item['priceKopecks'], "item {$itemIndex} priceKopecks");
if ($item['priceKopecks'] > self::MaxPriceKopecks) {
throw new InvalidArgumentException("Item {$itemIndex}: priceKopecks is too large");
}
if ($item['quantity'] < 1 || $item['quantity'] > self::MaxQuantity) {
throw new InvalidArgumentException("Item {$itemIndex}: quantity must be between 1 and " . self::MaxQuantity);
}
return $item['priceKopecks'] * $item['quantity'];
}
private function assertDiscountPercent(int $discountPercent): void
{
if ($discountPercent < 0 || $discountPercent > self::MaxDiscountPercent) {
throw new InvalidArgumentException('discountPercent must be between 0 and ' . self::MaxDiscountPercent);
}
}
private function assertNonNegativeMoney(int $valueKopecks, string $fieldName): void
{
if ($valueKopecks < 0) {
throw new InvalidArgumentException($fieldName . ' must be greater than or equal to 0');
}
}
}
4. src/DebugLogger.php
<?php
declare(strict_types=1);
namespace HelpfulCode\PhpQualityExample;
use RuntimeException;
final class DebugLogger
{
private string $logFile;
private bool $debugEnabled;
public function __construct(string $logFile, bool $debugEnabled = false)
{
$this->logFile = $logFile;
$this->debugEnabled = $debugEnabled;
$logDir = dirname($logFile);
if (!is_dir($logDir) && !mkdir($logDir, 0775, true) && !is_dir($logDir)) {
throw new RuntimeException('Cannot create log directory: ' . $logDir);
}
}
public function debug(string $message, array $context = []): void
{
if (!$this->debugEnabled) {
return;
}
$this->write('debug', $message, $context);
}
public function error(string $message, array $context = []): void
{
$this->write('error', $message, $context);
}
private function write(string $level, string $message, array $context): void
{
$payload = [
'time' => date(DATE_ATOM),
'level' => $level,
'message' => $message,
'context' => $this->sanitizeContext($context),
];
$line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($line === false) {
$line = '{"level":"error","message":"Cannot encode log payload"}';
}
file_put_contents($this->logFile, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
}
private function sanitizeContext(array $context): array
{
$sanitized = [];
foreach ($context as $key => $value) {
$keyString = (string) $key;
if (preg_match('~password|token|secret|key~i', $keyString)) {
$sanitized[$keyString] = '[hidden]';
continue;
}
if (is_array($value)) {
$sanitized[$keyString] = $this->sanitizeContext($value);
continue;
}
$sanitized[$keyString] = $value;
}
return $sanitized;
}
}
5. bin/demo.php
<?php
declare(strict_types=1);
use HelpfulCode\PhpQualityExample\DebugLogger;
use HelpfulCode\PhpQualityExample\OrderPriceCalculator;
require_once __DIR__ . '/../vendor/autoload.php';
$debugEnabled = getenv('APP_DEBUG') === '1';
$logger = new DebugLogger(__DIR__ . '/../var/log/app.log', $debugEnabled);
$calculator = new OrderPriceCalculator();
try {
$items = [
[
'name' => 'SSL certificate setup',
'priceKopecks' => 150000,
'quantity' => 1,
],
[
'name' => 'PHP audit',
'priceKopecks' => 350000,
'quantity' => 2,
],
];
$logger->debug('Before calculation', [
'items' => $items,
'deliveryKopecks' => 0,
'discountPercent' => 10,
]);
$result = $calculator->calculate($items, 0, 10);
$logger->debug('After calculation', [
'result' => $result,
]);
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(0);
} catch (Throwable $exception) {
$logger->error('Demo failed', [
'exception' => get_class($exception),
'message' => $exception->getMessage(),
]);
fwrite(STDERR, 'Error: ' . $exception->getMessage() . PHP_EOL);
exit(1);
}
6. tests/OrderPriceCalculatorTest.php
<?php
declare(strict_types=1);
namespace HelpfulCode\PhpQualityExample\Tests;
use HelpfulCode\PhpQualityExample\OrderPriceCalculator;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class OrderPriceCalculatorTest extends TestCase
{
public function testCalculateTotalWithDiscountAndDelivery(): void
{
$calculator = new OrderPriceCalculator();
$result = $calculator->calculate([
[
'priceKopecks' => 10000,
'quantity' => 2,
],
[
'priceKopecks' => 5500,
'quantity' => 1,
],
], 3000, 10);
self::assertSame(25500, $result['subtotalKopecks']);
self::assertSame(2550, $result['discountKopecks']);
self::assertSame(3000, $result['deliveryKopecks']);
self::assertSame(25950, $result['totalKopecks']);
self::assertSame('259.50 ₽', $result['totalFormatted']);
}
public function testCalculateTotalWithoutDiscount(): void
{
$calculator = new OrderPriceCalculator();
$result = $calculator->calculate([
[
'priceKopecks' => 9999,
'quantity' => 3,
],
]);
self::assertSame(29997, $result['totalKopecks']);
}
public function testEmptyItemsListThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Items list is empty');
$calculator = new OrderPriceCalculator();
$calculator->calculate([]);
}
public function testInvalidQuantityThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('quantity must be between 1 and 999');
$calculator = new OrderPriceCalculator();
$calculator->calculate([
[
'priceKopecks' => 10000,
'quantity' => 0,
],
]);
}
public function testInvalidDiscountThrowsException(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('discountPercent must be between 0 and 90');
$calculator = new OrderPriceCalculator();
$calculator->calculate([
[
'priceKopecks' => 10000,
'quantity' => 1,
],
], 0, 95);
}
public function testPriceMustBeInteger(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('priceKopecks must be integer');
$calculator = new OrderPriceCalculator();
$calculator->calculate([
[
'priceKopecks' => '10000',
'quantity' => 1,
],
]);
}
}
7. Запуск тестов
composer test
Ожидаемый результат:
OK (6 tests, 13 assertions)
8. Запуск demo-скрипта без debug
composer demo
Пример результата:
{
"subtotalKopecks": 850000,
"discountKopecks": 85000,
"deliveryKopecks": 0,
"totalKopecks": 765000,
"totalFormatted": "7 650.00 ₽"
}
9. Запуск demo-скрипта с debug
Linux / macOS / Git Bash:
APP_DEBUG=1 composer demo
PowerShell:
$env:APP_DEBUG="1"; composer demo
После запуска появится файл:
var/log/app.log
Пример строки лога:
{"time":"2026-06-25T10:00:00+02:00","level":"debug","message":"After calculation","context":{"result":{"subtotalKopecks":850000,"discountKopecks":85000,"deliveryKopecks":0,"totalKopecks":765000,"totalFormatted":"7 650.00 ₽"}}}
10. Общая проверка перед deploy
composer check
Команда последовательно выполнит тесты и demo-скрипт. Идея простая: если проект не проходит локальную проверку, выкатывать его нельзя.
Deploy через rsync
Файл scripts/deploy.sh. Выполнять локально на рабочей машине или в Git Bash, не на production-сервере.
#!/usr/bin/env bash
set -Eeuo pipefail
projectRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
releaseTag="$(date +%Y%m%d-%H%M%S)"
remoteUser="${DEPLOY_USER:-deploy}"
remoteHost="${DEPLOY_HOST:?Set DEPLOY_HOST}"
remotePath="${DEPLOY_PATH:?Set DEPLOY_PATH}"
cd "$projectRoot"
echo "1/5 Install dependencies"
composer install --no-interaction --prefer-dist
echo "2/5 Run tests"
composer test
echo "3/5 Prepare production autoload"
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
echo "4/5 Upload release"
ssh "${remoteUser}@${remoteHost}" "mkdir -p '${remotePath}/releases/${releaseTag}' '${remotePath}/shared/var/log'"
rsync -az --delete \
--exclude ".git/" \
--exclude ".phpunit.cache/" \
--exclude "tests/" \
--exclude "var/log/" \
--exclude ".env" \
"$projectRoot/" \
"${remoteUser}@${remoteHost}:${remotePath}/releases/${releaseTag}/"
echo "5/5 Activate release"
ssh "${remoteUser}@${remoteHost}" "
set -Eeuo pipefail
ln -sfn '${remotePath}/shared/var/log' '${remotePath}/releases/${releaseTag}/var/log'
ln -sfn '${remotePath}/releases/${releaseTag}' '${remotePath}/current'
find '${remotePath}/releases' -mindepth 1 -maxdepth 1 -type d | sort | head -n -5 | xargs -r rm -rf
"
echo "Deploy complete: ${releaseTag}"
echo "Restore local dev dependencies"
composer install --no-interaction --prefer-dist
Пример запуска deploy
chmod +x scripts/deploy.sh DEPLOY_USER=deploy \ DEPLOY_HOST=example.com \ DEPLOY_PATH=/var/www/php-quality-example \ ./scripts/deploy.sh
На сервере активная версия будет лежать по пути:
/var/www/php-quality-example/current
В nginx или другом веб-сервере лучше указывать именно на current/public, если у проекта есть публичная директория. В этом минимальном примере публичной директории нет, потому что демонстрация сделана для CLI-кода.
Краевые случаи
- Пустой список товаров выбрасывает исключение.
- Цена и количество должны быть целыми числами. Деньги хранятся в копейках, а не во float.
- Скидка ограничена диапазоном от
0до90процентов. - Количество ограничено, чтобы случайный импорт не создал абсурдный расчёт.
- Debug-лог не пишет данные, если
APP_DEBUGне равен1. - Ключи вроде
password,token,secret,keyмаскируются в логах.
Безопасность
- Не логируйте пароли, токены, cookies, содержимое сессий и персональные данные без необходимости.
- Не включайте
APP_DEBUG=1на production без веской причины. - Deploy-скрипт исключает
.env,.git, тесты и старые runtime-логи. - Для production лучше использовать отдельного SSH-пользователя
deployбез root-доступа. - Если проект вебовый, публичной должна быть только директория
public/, аsrc/,vendor/,var/не должны открываться напрямую из браузера.
Производительность
- Деньги считаются в integer-копейках: это быстрее и надёжнее, чем float для финансовых расчётов.
- Логи пишутся через
LOCK_EX, чтобы снизить риск перемешивания строк при параллельных запусках. - На production Composer запускается с
--no-dev --optimize-autoloader. - Для больших веб-проектов лучше добавить OPcache и не выполнять Composer на каждый запрос.
Совместимость
- Код рассчитан на PHP 8.2+.
- Для PHP 8.3+ можно заменить PHPUnit на
^12.5. - Для PHP 8.4+ можно заменить PHPUnit на
^13. - Для PHP 8.0/8.1 понадобится более старая ветка PHPUnit и лёгкая адаптация окружения.
- Deploy-скрипт рассчитан на Linux/macOS/Git Bash. В чистом Windows CMD/PowerShell команды
rsyncиsshмогут потребовать WSL или Git Bash.
Тест / проверка
composer install composer check APP_DEBUG=1 composer demo tail -n 5 var/log/app.log
Если тесты проходят, demo возвращает JSON, а debug-лог появляется только при APP_DEBUG=1, базовая механика работает.
Альтернативы
- PHPStan / Psalm: добавить статический анализ, чтобы ловить ошибки типов до запуска кода.
- Monolog: заменить простой
DebugLoggerна промышленное логирование с каналами, ротацией и форматтерами. - GitHub Actions / GitLab CI: запускать тесты автоматически при push.
- Deployer: использовать готовый PHP-инструмент для zero-downtime deploy.
- Docker: фиксировать окружение, если проект зависит от расширений PHP, MySQL, Redis или очередей.
Комментарии
0Нашли ошибку или хотите дополнить материал?