Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Розробка пакетів

Питання з реальних співбесід з відповідями: 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 - так ловляться помилки, які інакше знайде користувач на продакшені.

Докладніше в документації: Пакети: команди оптимізації