Питання на співбесіді: Розробка пакетів
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
8 питань
Laravel-пакет - це звичайний Composer-пакет, який інтегрується з фреймворком через сервіс-провайдер. Мінімальна структура:
acme/laravel-invoices/
├── composer.json
├── config/invoices.php
├── src/
│ ├── InvoicesServiceProvider.php
│ ├── InvoiceGenerator.php
│ └── Facades/Invoices.php
├── database/migrations/
├── resources/views/
└── tests/
Сервіс-провайдер - точка входу: реєструє класи в контейнері (register) і підключає конфігурацію, маршрути, представлення, міграції, команди (boot).
class InvoicesServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/invoices.php', 'invoices');
$this->app->singleton(InvoiceGenerator::class);
}
public function boot(): void
{
$this->loadViewsFrom(__DIR__.'/../resources/views', 'invoices');
}
}
Автоматичне виявлення (package discovery): провайдер і фасади описуються в composer.json пакета:
{
"name": "acme/laravel-invoices",
"autoload": {
"psr-4": { "Acme\\Invoices\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\Invoices\\InvoicesServiceProvider"],
"aliases": { "Invoices": "Acme\\Invoices\\Facades\\Invoices" }
}
}
}
Після composer require скрипт php artisan package:discover (Laravel запускає його в post-autoload-dump) читає секції extra.laravel усіх встановлених пакетів і записує список у bootstrap/cache/packages.php. Користувачу не треба нічого додавати в bootstrap/providers.php.
Як вимкнути виявлення - у composer.json застосунку:
"extra": {
"laravel": {
"dont-discover": ["barryvdh/laravel-debugbar"]
}
}
Наприклад, щоб підключати налагоджувальний пакет лише в локальному середовищі через власний провайдер.
Якщо пакет «не підхопився»: перевірити bootstrap/cache/packages.php, виконати php artisan package:discover і переконатися, що composer install не запускався з --no-scripts (тоді discover не виконується).
Залежності пакета: вимагати лише ті компоненти, які використовуються (illuminate/support, illuminate/database), а не весь laravel/framework - так пакет легше встановити й у застосунки на компонентах Laravel.
Коли пакет пишеться для конкретного застосунку (або виділяється з нього), незручно щоразу публікувати версію, щоб перевірити зміну. Path-репозиторій підключає пакет з локального каталогу.
~/code/
├── shop/ # застосунок
└── packages/
└── laravel-invoices/ # пакет
У composer.json застосунку:
{
"repositories": [
{
"type": "path",
"url": "../packages/laravel-invoices",
"options": { "symlink": true }
}
],
"require": {
"acme/laravel-invoices": "@dev"
}
}
composer update acme/laravel-invoices
Composer створює символьне посилання vendor/acme/laravel-invoices → ../packages/laravel-invoices. Зміни в коді пакета видно в застосунку одразу, без повторного встановлення.
Що варто знати:
- версія:
@devбере поточний стан каталогу. Якщо в пакеті є Git-теги чи полеversion, можна вимагати й звичайне обмеження (^1.0); - автозавантаження: після додавання нових класів у пакет зазвичай достатньо symlink, але при зміні секції
autoloadуcomposer.jsonпакета -composer dump-autoloadу застосунку; - package discovery спрацьовує як для звичайного пакета;
- у Docker symlink на каталог поза проєктом не працюватиме, якщо цей каталог не змонтовано в контейнер. Часто пакет тримають усередині репозиторію (
packages/у корені) з"url": "packages/*"; symlink: false- копіювання замість посилання: стабільніше для CI чи образів, але зміни потребуютьcomposer update.
Не забути перед публікацією:
- прибрати path-репозиторій з
composer.jsonзастосунку (чи тримати його лише локально) і вимагати опубліковану версію; - перевірити, що пакет встановлюється «з нуля» без застосунку - тести через Orchestra Testbench саме це й перевіряють.
Альтернатива для приватних пакетів: vcs-репозиторій (Git URL), Private Packagist чи Satis - тоді пакет встановлюється з тегів, як публічний.
Стартовий шаблон: замість ручного створення структури можна взяти spatie/package-skeleton-laravel - там уже налаштовані Testbench, Pest, PHPStan, GitHub Actions і публікація конфігурації.
Пакет має працювати одразу після встановлення з розумними значеннями за замовчуванням, але дозволяти змінити їх застосунку.
mergeConfigFrom (у register) - злиття конфігурації пакета з конфігурацією застосунку:
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/invoices.php', 'invoices');
}
Тепер config('invoices.currency') працює, навіть якщо застосунок нічого не публікував.
publishes (у boot) - дозволяє скопіювати файл у застосунок для редагування:
public function boot(): void
{
$this->publishes([
__DIR__.'/../config/invoices.php' => config_path('invoices.php'),
], 'invoices-config');
}
php artisan vendor:publish --tag=invoices-config
Як працює злиття - і головна пастка:
// всередині mergeConfigFrom
array_merge(require $packageConfig, config('invoices', []));
- значення застосунку перемагають значення пакета;
- злиття неглибоке - лише перший рівень ключів. Якщо пакет має
'pdf' => ['size' => 'A4', 'orientation' => 'portrait'], а застосунок у своєму файлі вказав'pdf' => ['size' => 'Letter'], ключorientationзникне повністю; - для рекурсивного злиття є
replaceConfigRecursivelyFrom(), але воно має свої наслідки для масивів-списків.
Звідси правило проєктування конфігурації пакета: тримати її пласкою, а вкладені групи - мінімальними, і документувати, що опублікований файл замінює групу цілком.
config:cache: коли конфігурацію закешовано, mergeConfigFrom нічого не робить - значення вже в кеші. Тому змінюючи конфігурацію пакета в розробці, не забувайте config:clear.
Теги публікації:
- конвенція -
<пакет>-config,<пакет>-migrations,<пакет>-views; vendor:publish --provider="Acme\Invoices\InvoicesServiceProvider"публікує все від провайдера;- міграції краще публікувати через
publishesMigrations()- Laravel оновить мітку часу в імені файлу, щоб міграція виконалася після наявних; - не змушуйте публікувати конфігурацію для базової роботи: якщо без
vendor:publishпакет падає - це погана інтеграція.
Значення з оточення: у конфігурації пакета можна використовувати env('INVOICES_CURRENCY', 'UAH'), а в коді пакета - лише config(), бо після config:cache виклики env() поза конфігураційними файлами повертають null.
Усе підключається в методі boot сервіс-провайдера пакета:
public function boot(): void
{
$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->loadViewsFrom(__DIR__.'/../resources/views', 'invoices');
$this->loadTranslationsFrom(__DIR__.'/../lang', 'invoices');
$this->loadJsonTranslationsFrom(__DIR__.'/../lang');
if ($this->app->runningInConsole()) {
$this->commands([InstallCommand::class]);
}
}
Використання з простором імен пакета:
return view('invoices::pdf', ['invoice' => $invoice]);
__('invoices::messages.paid');
<x-invoices::status-badge :status="$invoice->status" />
Як застосунок перевизначає представлення без форку пакета: loadViewsFrom реєструє два шляхи - спершу resources/views/vendor/invoices застосунку, потім каталог пакета. Достатньо опублікувати чи створити файл з тим самим іменем:
$this->publishes([
__DIR__.'/../resources/views' => resource_path('views/vendor/invoices'),
], 'invoices-views');
Переклади працюють так само: файли в lang/vendor/invoices застосунку мають пріоритет.
Особливості кожного ресурсу:
| Ресурс | На що зважати |
|---|---|
| маршрути | loadRoutesFrom нічого не робить, якщо маршрути закешовано; дайте можливість вимкнути маршрути чи змінити префікс і middleware через конфігурацію |
| міграції | loadMigrationsFrom запускає міграції пакета разом з міграціями застосунку - зручно, але застосунок не може їх змінити. Альтернатива - лише публікація |
| представлення | ім'я простору - унікальне, щоб не зіткнутися з іншими пакетами |
| компоненти | Blade::componentNamespace('Acme\\Invoices\\Views', 'invoices') чи анонімні компоненти з каталогу представлень |
| команди | реєструвати лише в консолі - runningInConsole() |
Маршрути пакета - найбільша зона конфліктів: пакет, що безумовно реєструє /login чи /admin, може перекрити маршрути застосунку. Гарна практика:
if (config('invoices.routes.enabled', true)) {
Route::prefix(config('invoices.routes.prefix', 'invoices'))
->middleware(config('invoices.routes.middleware', ['web', 'auth']))
->group(__DIR__.'/../routes/web.php');
}
Міграції з моделями користувача: таблиці, що посилаються на users, мають враховувати, що модель користувача й тип ключа (int, UUID) у застосунку можуть бути іншими - краще брати їх із конфігурації.
Пакет не має власного застосунку - немає bootstrap/app.php, контейнера, конфігурації. Orchestra Testbench створює мінімальний Laravel-застосунок для тестів пакета.
composer require --dev orchestra/testbench pestphp/pest
Версії Testbench відповідають версіям Laravel:
| Testbench | Laravel |
|---|---|
| 9.x | 11.x |
| 10.x | 12.x |
| 11.x | 13.x |
Базовий тест-кейс:
namespace Acme\Invoices\Tests;
use Acme\Invoices\InvoicesServiceProvider;
use Orchestra\Testbench\TestCase as Orchestra;
abstract class TestCase extends Orchestra
{
protected function getPackageProviders($app): array
{
return [InvoicesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('invoices.currency', 'UAH');
}
protected function defineDatabaseMigrations(): void
{
$this->loadLaravelMigrations();
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
}
// tests/Pest.php
uses(Acme\Invoices\Tests\TestCase::class)->in(__DIR__);
Тепер у тестах працює все, що й у застосунку: HTTP-тести маршрутів пакета, фабрики, Mail::fake(), artisan():
it('generates an invoice pdf', function () {
$this->get('/invoices/1/pdf')->assertOk()->assertHeader('content-type', 'application/pdf');
});
it('publishes the config', function () {
$this->artisan('vendor:publish', ['--tag' => 'invoices-config'])->assertSuccessful();
});
Workbench - для ручної перевірки: vendor/bin/testbench workbench:install створює каталог workbench/ з маршрутами й моделями, а vendor/bin/testbench serve запускає вебсервер з підключеним пакетом.
Що обов'язково протестувати в пакеті:
- пакет працює без опублікованої конфігурації;
- реєстрація провайдера не падає за відсутності необов'язкових залежностей;
- міграції застосовуються й відкочуються;
- поведінку на всіх підтримуваних версіях Laravel і PHP - матриця в CI (
laravel: [12.*, 13.*]з відповідними версіями Testbench); --prefer-lowestу CI - що пакет справді працює з мінімальними версіями, вказаними вcomposer.json.
Користувачі пакета оновлюють Laravel у різний час. Пакет, що підтримує лише найновішу версію, блокує їм оновлення - або змушує шукати альтернативу.
Обмеження версій у composer.json:
"require": {
"php": "^8.3",
"illuminate/support": "^12.0|^13.0",
"illuminate/database": "^12.0|^13.0"
},
"require-dev": {
"orchestra/testbench": "^10.0|^11.0",
"pestphp/pest": "^4.0|^5.0"
}
Матриця в CI - кожна комбінація перевіряється окремо:
strategy:
matrix:
php: [8.3, 8.4, 8.5]
laravel: [12.*, 13.*]
stability: [prefer-lowest, prefer-stable]
include:
- laravel: 12.*
testbench: 10.*
- laravel: 13.*
testbench: 11.*
exclude:
- laravel: 13.*
php: 8.3 # якщо Laravel 13 вимагає новішу версію PHP
steps:
- run: composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-update
- run: composer update --${{ matrix.stability }} --prefer-dist
prefer-lowest ловить випадки, коли код використовує метод, якого ще немає в мінімальній заявленій версії.
Як писати код для кількох версій:
- спільний знаменник API: нові можливості фреймворку використовувати лише тоді, коли мінімальна версія їх містить;
- якщо дуже треба - перевірка можливостей, а не номера версії:
method_exists(Builder::class, 'whereVectorSimilarTo'),class_exists(...); - не покладатися на внутрішні класи й поведінку, не описану в документації, - вони змінюються в мінорних версіях;
version_compare(app()->version(), ...)- крайній засіб.
Семантичне версіонування пакета:
| Зміна | Версія |
|---|---|
| виправлення без зміни API | патч 1.4.1 |
| нова можливість, сумісна назад | мінорна 1.5.0 |
| видалено чи змінено публічний API, ключ конфігурації, підпис методу, структуру міграцій | мажорна 2.0.0 |
| зняття підтримки старої версії Laravel чи PHP | зазвичай мажорна (хоча Composer сам не встановить пакет на непідтримуваній версії, багато супровідників вважають це breaking) |
Що вважається публічним API пакета: публічні класи й методи, ключі конфігурації, імена подій, теги публікації, структура таблиць, назви команд. Позначайте внутрішнє @internal і робіть класи final, щоб звузити поверхню, яку доведеться зберігати.
Політика підтримки: записати в README, які версії Laravel і PHP підтримуються і скільки часу стара мажорна версія пакета отримує виправлення безпеки.
Пакет живе в чужому застосунку. Що менше він нав'язує, то легше його встановити, налаштувати й замінити.
Залежності в коді пакета - через інтерфейси й контейнер:
namespace Acme\Invoices\Contracts;
interface PdfRenderer
{
public function render(string $html): string;
}
// InvoicesServiceProvider::register()
$this->app->singleton(PdfRenderer::class, fn ($app) => new DompdfRenderer(
$app['config']->get('invoices.pdf'),
));
$this->app->singleton(InvoiceGenerator::class);
final class InvoiceGenerator
{
public function __construct(
private PdfRenderer $renderer,
private Filesystem $files, // Illuminate\Contracts\Filesystem\Filesystem
) {}
}
Застосунок замінює реалізацію одним рядком у своєму провайдері - без форку пакета:
$this->app->singleton(PdfRenderer::class, BrowsershotRenderer::class);
Фасади:
- фасад - зручність для користувача, а не спосіб писати внутрішній код пакета. Усередині пакета краще впровадження залежностей: код тестується без фасадів і не залежить від глобального стану;
- фасад пакета вказує на прив'язку в контейнері (
getFacadeAccessorповертає клас чи ключ), тож заміна реалізації працює й для нього; - реєструвати аліас через
extra.laravel.aliases- необов'язково: багато користувачів імпортують клас фасаду напряму.
Необов'язкові залежності:
"require": { "illuminate/support": "^12.0|^13.0" },
"suggest": { "spatie/browsershot": "Для рендерингу PDF через Chrome" }
if (! class_exists(\Spatie\Browsershot\Browsershot::class)) {
throw new MissingDependency('Install spatie/browsershot to use the browsershot driver.');
}
Важка залежність (headless Chrome, SDK хмари) не має ставитися всім, кому потрібна лише частина пакета.
Моделі застосунку:
- не імпортувати
App\Models\User- модель береться з конфігурації:config('invoices.user_model', config('auth.providers.users.model')); - власні моделі пакета дозволяти розширювати:
config('invoices.models.invoice')і використовувати їх через цей ключ.
Події замість жорстких викликів: InvoicePaid дозволяє застосунку реагувати (лист, вебхук, бухгалтерія), не змінюючи код пакета.
Ознака добре спроєктованого пакета: його можна використовувати без фасадів, без публікації файлів і з власною реалізацією кожної зовнішньої залежності.
На продакшені застосунок кешує конфігурацію, маршрути, події й представлення (php artisan optimize). Пакет, який цього не враховує, працює локально й ламається після деплою.
Що змінюється, коли кеші ввімкнені:
| Кеш | Наслідок для пакета |
|---|---|
config:cache |
mergeConfigFrom пропускається - значення беруться з кешу; env() у коді пакета повертає null |
route:cache |
loadRoutesFrom пропускається; замикання в маршрутах пакета раніше ламали кешування - краще контролери |
event:cache |
слухачі, знайдені автоматично, кешуються; динамічна реєстрація в рантаймі може не потрапити |
view:cache |
компілює представлення, зокрема пакетів, зареєстровані через loadViewsFrom |
Типові помилки пакетів:
env()поза файлом конфігурації пакета - післяconfig:cacheповертаєnull. Усе, що залежить від оточення, має проходити черезconfig/<пакет>.php;- конфігурація, змінена в рантаймі (
config(['invoices.x' => ...])уbootзалежно від запиту) - не потрапляє в кеш і поводиться по-різному з кешем і без; - несеріалізовані значення в конфігурації: замикання чи об'єкти в
config/*.phpламаютьconfig:cache(«Your configuration files are not serializable»). Замість замикання - ім'я класу; - важка робота в
boot: запити до бази чи HTTP при кожному завантаженні застосунку, зокрема в консольних командах і вpackage:discoverпід часcomposer install(коли бази ще може не бути).
Інтеграція з командами застосунку:
public function boot(): void
{
if ($this->app->runningInConsole()) {
// викликаються з optimize / optimize:clear
$this->optimizes(
optimize: 'invoices:cache',
clear: 'invoices:clear',
);
// викликається з php artisan reload (перезапуск довгоживучих процесів)
$this->reloads('invoices:restart-workers');
}
// рядок у php artisan about
AboutCommand::add('Invoices', fn () => [
'Version' => '2.3.0',
'PDF driver' => config('invoices.pdf.driver'),
]);
}
Тоді власний кеш пакета (наприклад, скомпільовані шаблони чи маніфест) створюється й очищується разом зі стандартними кешами, і розробнику не треба пам'ятати окрему команду в скрипті деплою.
Octane і довгоживучі процеси: пакет не має зберігати стан запиту в синглтонах чи статичних властивостях - у воркері Octane він «протікає» між запитами. Для даних запиту - scoped() прив'язки замість singleton().
Тест у CI пакета: прогнати набір тестів ще й після config:cache і route:cache у Testbench - так ловляться помилки, які інакше знайде користувач на продакшені.