PHP: Мини-проект: код, тесты, debug и deploy

Этот пример показывает механику нормальной разработки на 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 или очередей.
Просмотров: 130
Материал помог? Оценка помогает находить устаревшие и спорные решения.

Комментарии

0

Нашли ошибку или хотите дополнить материал?

Комментариев пока нет.Можно первым дополнить материал или сообщить об ошибке.
Telegram