• Час читання ~5 хв
  • 10.08.2022

Обмін кодом ніколи не був таким доступним, а встановлення пакетів PHP стало зручнішим; однак створення пакетів? У цьому посібнику я розповім, як створити та опублікувати новий пакет Laravel. Перегляд налаштувань та інструментів, які можна використовувати, щоб забезпечити якість вашого пакета та переконатися, що якщо ви створюєте та публікуєте щось, ви робите це добре.

Що ж ми збираємося робити будувати?Який пакет ми могли б створити, достатньо простий, щоб вам було легко вивчити процес, але мав достатньо частин, щоб зрозуміти його. Ми створимо пакет із командою artisan, яка дозволить нам створювати об’єкти передачі даних у Laravel і PHP 8.1, сподіваючись оновити до PHP 8.2, щойно він стане доступним.Окрім цього, ми також матимемо фасад для гідратації об’єктів передачі даних, які тут називаються DTO.

Отже, з чого почати створення нового пакета? Яким має бути наш перший крок? По-перше, що мені подобається робити, коли я збираюся створити пакунок, це шукати пакувальника, щоб переконатися, що я не будую щось уже доступне чи достатньо багатофункціональне, щоб я витрачав свій час.Зрештою, ми не хочемо відтворювати колесо.

Коли я впевнений, що будую щось корисне, чого не існує, я думаю про те, що потрібно моєму пакету. У нашому випадку наші вимоги відносно прості. У нас буде 3-4 основні класи, які ми хочемо створити, і все. Вирішення структури вашого пакету зазвичай є одним із перших кроків, які ви повинні подолати.Як ви можете створити цей код, щоб поділитися ним з іншими у спосіб, до якого люди звикли? На щастя, спільнота Laravel допоможе вам у цьому. Сховища шаблонів доступні для скелетів пакунків; їх потрібно лише шукати. Такі компанії, як Spatie і Beyond Code, мають одні з найкращих скелетів пакетів, які постачаються повнофункціональними та заощадять вам багато часу.

Однак у цьому підручнику я не використовуватиму базовий пакет, оскільки вважаю, що важливо навчитися виконувати завдання, перш ніж використовувати інструмент для виконання роботи за вас. Тож почнемо з чистого аркуша. Спочатку вам потрібно придумати назву для свого пакета.Я збираюся назвати свій «Інструменти для об’єктів даних Laravel», оскільки зрештою я хотів би створити набір інструментів, щоб мати змогу легше працювати з DTO у своїй програмі. Він розповідає людям, яка мета мого пакета, і дозволяє мені розширювати його з часом.

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

git init

Тепер, коли у нас є репозиторій для роботи, ми знаємо, що ми зможемо передати речі в систему контролю джерельних кодів і дозволити нам версії нашого пакета, коли прийде час. Щоб створити пакет PHP, одразу потрібно мати одну річ — composer.jsonфайл, який розповість Packagist, що це за пакет і що йому потрібно для запуску. Ви можете скористатися інструментом композитора командного рядка або створити файл композитора вручну. Зазвичай я використовую командний рядок composer init, оскільки це інтерактивний спосіб налаштування; однак я покажу результат початку мого файлу композитора, щоб ви могли побачити результат:

{
  "name": "juststeveking/laravel-data-object-tools",
  "description": "A set of tools to make working with Data Transfer Objects easier in Laravel",
  "type": "library",
  "license": "MIT",
  "authors": [
    {
      "role": "Developer",
      "name": "Steve McDougall",
      "email": "[email protected]",
      "homepage": "https://www.juststeveking.uk/"
    }
  ],
  "autoload": {
    "psr-4": {
      "JustSteveKing\\DataObjects\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "JustSteveKing\\DataObjects\\Tests\\": "tests/"
    }
  },
  "require": {
    "php": "^8.1"
  },
  "require-dev": {},
  "minimum-stability": "dev",
  "prefer-stable": true,
  "config": {
    "sort-packages": true,
    "preferred-install": "dist",
    "optimize-autoloader": true
  }
}

Це основа більшості моїх пакетів, і незалежно від того, чи це пакет Laravel, чи звичайний пакет PHP, це налаштовує мене таким чином, що я знаю, що матиму послідовність. Щоб почати, нам потрібно буде додати кілька допоміжних файлів до нашого пакета. По-перше, нам потрібно додати наш файл .gitignore, щоб ми могли вказати контролю версій, які файли та каталоги ми не хочемо фіксувати:

/vendor/
/.idea
composer.lock

Це початок файлів, які ми хочемо ігнорувати. Я використовую PHPStorm, який додасть мета-каталог під назвою .idea, який міститиме всю інформацію, необхідну моєму IDE для розуміння мого проекту – те, чого я не хочу передати контролю версій. Далі нам потрібно додати кілька атрибутів git, щоб контроль версій знав, як обробляти наше сховище. Це називається .gitattributes:

* text=auto

*.md diff=markdown
*.php diff=php

/.github export-ignore
/tests export-ignore
.editorconfig export-ignore
.gitattributes export-ignore
.gitignore export-ignore
CHANGELOG.md export-ignore
phpunit.xml export-ignore

Під час створення випуску ми повідомляємо нашому постачальнику засобів керування джерелами, які файли ми хочемо ігнорувати та як обробляти відмінності. Нарешті, нашим останнім допоміжним файлом буде .editorconfig, який повідомляє нашому редактору коду, як обробляти файли, які ми пишемо:

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

[*.{yml,yaml,json}]
indent_size = 2
< p>Тепер, коли у нас є допоміжні файли для керування версіями та наш редактор, ми можемо почати думати про те, що потрібно нашому пакету щодо залежностей. На які залежності буде спиратися наш пакет і які версії ми використовуємо? Давайте почнемо.

Оскільки ми створюємо пакет Laravel, перше, що нам знадобиться, це пакет підтримки Laravels, тому встановіть його за допомогою такої команди композитора:

composer require illuminate/support

Тепер, коли у нас є що почнемо з того, що давайте розглянемо першу важливу частину коду, яка знадобиться нашому пакету; Постачальник послуг.Постачальник послуг є важливою частиною будь-якого пакета Laravel, оскільки він повідомляє Laravel, як завантажити пакет і що доступно. Для початку ми хочемо повідомити Laravel, що у нас є консольна команда, яку ми можемо використовувати після встановлення. Я подзвонив своєму постачальнику послуг PackageServiceProvider, оскільки в мене немає уяви, і назвати речі важко.Не соромтеся змінювати власне ім’я, якщо хочете. Я додаю свого постачальника послуг у src/Providers, оскільки він знайомий із програмою Laravel.

declare(strict_types=1);
 
namespace JustSteveKing\DataObjects\Providers;
 
use Illuminate\Support\ServiceProvider;
use JustSteveKing\DataObjects\Console\Commands\DataTransferObjectMakeCommand;
 
final class PackageServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        if ($this->app->runningInConsole()) {
            $this->commands(
                commands: [
                    DataTransferObjectMakeCommand::class,
                ],
            );
        }
    }
}

Зазвичай я створюю класи, яких не хочу буде розширено остаточно, оскільки це змінить те, як я хочу, щоб пакет працював. Вам не потрібно цього робити. Це вирок, який ви повинні зробити для себе.Отже, ми маємо зареєстровану команду. Нам слід подумати про створення цього. Як ви можете зрозуміти з назви, це команда, яка генеруватиме для нас інші класи, трохи відрізняючись від вашої типової команди ремісника.

Я створив клас називається DataTransferObjectMakeCommand, який є дуже багатослівним, але пояснює, що він робить у src/Console/Commands.Як бачите, створюючи ці класи, я намагаюся відобразити структуру каталогів, знайому розробникам Laravel. Це значно полегшує роботу з пакетом. Давайте подивимося на код цієї команди:

declare(strict_types=1);
 
namespace JustSteveKing\DataObjects\Console\Commands;
 
use Illuminate\Console\GeneratorCommand;
use Illuminate\Support\Str;
 
final class DataTransferObjectMakeCommand extends GeneratorCommand
{
    protected $signature = "make:dto {name : The DTO Name}";
 
    protected $description = "Create a new DTO";
 
    protected $type = 'Data Transfer Object';
 
    protected function getStub(): string
    {
        $readonly = Str::contains(
            haystack: PHP_VERSION,
            needles: '8.2',
        );
 
        $file = $readonly ? 'dto-82.stub' : 'dto.stub';
 
        return __DIR__ . "/../../../stubs/{$file}";
    }
 
    protected function getDefaultNamespace($rootNamespace): string
    {
        return "{$rootNamespace}\\DataObjects";
    }
}

Давайте пройдемося по цій команді, щоб зрозуміти, що ми створюємо. Наша команда хоче розширити GeneratorCommand, оскільки ми хочемо створити новий файл.Це корисно зрозуміти, оскільки документації про те, як це зробити, мало. Єдине, що нам потрібно для цієї команди, — це метод під назвою getStub — це те, що команді потрібно знати, як завантажити розташування файлу-заглушки, щоб допомогти у створенні файлу. Я створив каталог у корені мого пакета під назвою stubs, знайоме місце для програм Laravel. Тут ви побачите, що я перевіряю встановлену версію PHP, щоб перевірити, чи ми використовуємо PHP 8.2, і якщо ми працюємо, то ми хочемо завантажити правильну версію заглушки, щоб скористатися перевагами класів лише для читання. Зараз шанси на це досить низькі, але ми ще не так далеко.Цей підхід допомагає генерувати файли для певних версій PHP, тож ви можете забезпечити підтримку кожної версії, яку бажаєте підтримувати.

Нарешті, я встановив простір імен за умовчанням для своїх DTO. , тому я знаю, де я хочу, щоб вони жили. Зрештою, я не хочу переповнювати кореневий простір імен.

Спочатку давайте швидко розглянемо ці файли-заглушки, стандартну заглушку:

Наш DTO запровадить контракт, щоб гарантувати узгодженість - те, що я люблю робити з якомога більшою кількістю класів. Крім того, наш клас DTO є остаточним. Ймовірно, ми не захочемо розширювати цей клас, тому зробити цей клас остаточним за замовчуванням є розумним підходом. Тепер давайте подивимося на версію PHP 8.2:

<?php
 
declare(strict_types=1);
 
namespace {{ namespace }};
 
use JustSteveKing\DataObjects\Contracts\DataObjectContract;
 
final class {{ class }} implements DataObjectContract
{
    public function __construct(
        //
    ) {}
 
    public function toArray(): array
    {
        return [];
    }
}

Єдина відмінність тут полягає в тому, що ми робимо наш клас DTO доступним лише для читання, щоб скористатися перевагами новіших можливостей мови.

<?php
 
declare(strict_types=1);
 
namespace {{ namespace }};
 
use JustSteveKing\DataObjects\Contracts\DataObjectContract;
 
readonly class {{ class }} implements DataObjectContract
{
    public function __construct(
        //
    ) {}
 
    public function toArray(): array
    {
        return [];
    }
}

Як ми можемо це перевірити? По-перше, ми хочемо встановити тестовий пакет, який дозволить нам переконатися, що ми можемо писати тести для виконання цієї команди - я буду використовувати pestPHP для це, але використання PHPUnit працюватиме дуже подібним чином.

Ця команда попросить вас дозволити pest використовувати плагіни композитора, тож переконайтеся, що ви відповіли «так», якщо вам потрібні плагіни pest для ваших тестів, наприклад паралельного тестування . Далі нам знадобиться пакет, який дозволить нам використовувати Laravel у наших тестах, щоб переконатися, що наш пакет працює ефективно. Цей пакет називається Testbench, і я клянусь ним, створюючи пакети Laravel.

composer require pestphp/pest --dev --with-all-dependencies

Найпростіший спосіб ініціалізувати набір тестів у нашому пакеті — це використати pestPHP для його ініціалізації. Виконайте таку консольну команду:

composer require --dev orchestra/testbench

Це створить файл phpunit.xml і tests/Pest.php файл, який ми використовуємо для контролю та розширення самого шкідника. По-перше, я хочу внести кілька змін у файл конфігурації PHPUnit, який використовуватиме шкідник.Мені подобається додавати такі параметри, щоб спростити моє тестування:

./vendor/bin/pest --init

Я роблю це, тому що якщо тест проходить невдало, я хочу знати про це негайно. Передчасні повернення та збої — це речі, які допомагають нам створити щось, у що ми маємо більше впевненості. Кешування результатів пришвидшує тестування вашого пакета.Однак я хочу щоразу запускати свій набір тестів з нуля, щоб переконатися, що він працює так, як я очікую.

stopOnFailure I set to true
cacheResults I set to false

Давайте звернемо нашу увагу на тест за замовчуванням випадок, для якого нам потрібні тести пакетів. Створіть новий файл у tests/PackageTestCase.php, щоб ми могли легше контролювати наші тести.

Наш PackageTestCase розширює тестовий стенд TestCase, щоб ми могли запозичити поведінку з пакета для створення нашого набору тестів. Потім ми реєструємо нашого постачальника пакетних послуг, щоб переконатися, що наш пакет завантажується в тестову програму.

declare(strict_types=1);
 
namespace JustSteveKing\DataObjects\Tests;
 
use JustSteveKing\DataObjects\Providers\PackageServiceProvider;
use Orchestra\Testbench\TestCase;
 
class PackageTestCase extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [
            PackageServiceProvider::class,
        ];
    }
}

Тепер давайте подивимося, як ми можемо перевірити це. Перш ніж писати наші тести, ми хочемо переконатися, що те, що ми тестуємо, охоплює поточну поведінку пакета.Наразі все, що робить наш тест, це надає команду, за допомогою якої можна створити новий файл. Наша структура каталогу тестів буде відображати структуру нашого пакета, тому створіть наш перший тестовий файл у tests/Console/Commands/DataTransferObjectMakeCommandTest.php і розпочнімо наш перший тест.

Перш ніж ми напишемо наш перший тест, нам потрібно відредагувати tests/Pest.phpфайл, щоб переконатися, що наш набір тестів використовує наш PackageTestCase належним чином.

Почати з того, ми хочемо переконатися, що ми можемо виконати нашу команду та що він працює успішно. Тому додайте наступний тест:

Ми перевіряємо, що коли ми викликаємо цю команду, вона виконується без помилок. Один із найбільш критичних тестів, якщо ви запитаєте мене, якщо він помиляється, це означає, що щось пішло не так.

declare(strict_types=1);
 
use JustSteveKing\DataObjects\Tests\PackageTestCase;
 
uses(PackageTestCase::class)->in(__DIR__);

Тепер, коли ми знаємо, що наш тест може виконуватися, ми також хочемо переконатися, що класи створені. Давайте напишемо цей тест далі:

declare(strict_types=1);
 
use JustSteveKing\DataObjects\Console\Commands\DataTransferObjectMakeCommand;
 
use function PHPUnit\Framework\assertTrue;
 
it('can run the command successfully', function () {
    $this
        ->artisan(DataTransferObjectMakeCommand::class, ['name' => 'Test'])
        ->assertSuccessful();
});

Тут ми використовуємо Pest Dataset для запуску деяких параметрів, схоже на PHPUnit Data Provider. Ми переглядаємо кожен параметр і викликаємо нашу команду, підтверджуючи, що файл існує.Тепер ми знаємо, що ми можемо передати назву нашій команді artisan і створити DTO для використання в нашій програмі.

Нарешті, ми хочемо створити фасад для наш пакет для легкої гідратації наших DTO. Наявність DTO часто – це лише половина справи, і так, ми могли б додати метод до самого DTO для статичних викликів, але ми можемо значно спростити цей процес.Ми сприятимемо цьому, використовуючи дійсно корисний пакет від Франка де Йонга у його Пакет Eventsauce, який називається "object hydrator". Щоб установити це, виконайте таку команду композитора:

declare(strict_types=1);
 
use Illuminate\Support\Facades\File;
use JustSteveKing\DataObjects\Console\Commands\DataTransferObjectMakeCommand;
 
use function PHPUnit\Framework\assertTrue;
 
it('create the data transfer object when called', function (string $class) {
    $this->artisan(
        DataTransferObjectMakeCommand::class,
        ['name' => $class],
    )->assertSuccessful();
 
    assertTrue(
        File::exists(
            path: app_path("DataObjects/$class.php"),
        ),
    );
})->with('classes');

Настав час створити оболонку навколо цього пакета, щоб ми могли його добре використовувати, тож давайте створимо новий клас у src/Hydrator/Hydrate.php, і ми також створимо контракт разом із ним, якщо ми бажаєте змінити реалізацію в будь-який момент. Це буде src/Contracts/HydratorContract.php. Давайте почнемо з контракту, щоб зрозуміти, що ми хочемо зробити.

Усе, що нам потрібно, це спосіб гідратації об’єкта, тому ми беремо ім’я класу об’єкта та масив властивостей, щоб повернути об’єкт даних. Давайте тепер подивимося на реалізацію:

composer require eventsauce/object-hydrator

У нас є відображувач об’єктів, переданий у конструктор або створений у конструкторі, який ми потім використовуємо всередині методу fill. Потім метод fill використовує програму відображення для гідратації об’єкта.Він простий і чистий у використанні, і його можна легко відтворити, якщо ми вирішимо використовувати інший гідратор у майбутньому. Однак, використовуючи це, ми хочемо прив’язати гідратор до контейнера, щоб дозволити нам вирішити це за допомогою ін’єкції залежностей. Додайте наступне у верхній частині свого PackageServiceProvider:

declare(strict_types=1);
 
namespace JustSteveKing\DataObjects\Contracts;
 
interface HydratorContract
{
    /**
     * @param class-string<DataObjectContract> $class
     * @param array $properties
     * @return DataObjectContract
     */
    public function fill(string $class, array $properties): DataObjectContract;
}

Тепер, коли у нас є наш гідратор, нам потрібно створити фасад, щоб ми могли красиво називати його в наших програмах. Давайте створимо це зараз у src/Facades/Hydrator.php

declare(strict_types=1);
 
namespace JustSteveKing\DataObjects\Hydrator;
 
use EventSauce\ObjectHydrator\ObjectMapperUsingReflection;
use JustSteveKing\DataObjects\Contracts\DataObjectContract;
use JustSteveKing\DataObjects\Contracts\HydratorContract;
 
class Hydrate implements HydratorContract
{
    public function __construct(
        private readonly ObjectMapperUsingReflection $mapper = new ObjectMapperUsingReflection(),
    ) {}
 
    public function fill(string $class, array $properties): DataObjectContract
    {
        return $this->mapper->hydrateObject(
            className: $class,
            payload: $properties,
        );
    }
}

Тож наш Facade наразі повертає реалізацію подій hydrator, яка означає, що ми не можемо вирішити це з контейнера, тому, якщо ми змінимо реалізацію, нам доведеться змінити фасад.Однак наразі це не велика угода. Далі нам потрібно додати цей псевдонім до нашого файлу composer.json, щоб Laravel дізнавався про нього, коли ми встановлюємо пакет.

public array $bindings = [
    HydratorContract::class => Hydrate::class,
];

Зараз що ми зареєстрували наш фасад, нам потрібно перевірити, чи він працює належним чином. Давайте розглянемо, як це можна перевірити. Створіть новий тестовий файл у tests/Facades/HydratorTest.php і почнемо:

declare(strict_types=1);
 
namespace JustSteveKing\DataObjects\Facades;
 
use Illuminate\Support\Facades\Facade;
use JustSteveKing\DataObjects\Contracts\DataObjectContract;
use JustSteveKing\DataObjects\Hydrator\Hydrate;
 
/**
 * @method static DataObjectContract fill(string $class, array $properties)
 *
 * @see \JustSteveKing\DataObjects\Hydrator\Hydrate;
 */
final class Hydrator extends Facade
{
    /**
     * @return class-string
     */
    protected static function getFacadeAccessor(): string
    {
        return Hydrate::class;
    }
}

Ми створили новий набір даних під назвою strings, який повертає масив випадкових рядків для використання. Ми передаємо це в наш тест і намагаємося викликати метод заповнення на нашому фасаді. Переходячи в тестовий клас, ми можемо створити масив властивостей для гідратації. Потім ми перевіряємо, що екземпляр створено та відповідає нашим очікуванням, коли ми викликаємо метод toArray у DTO.Ми можемо використовувати API відображення, щоб переконатися, що наш DTO створено належним чином для нашого фінального тесту.

"extra": {
  "laravel": {
    "providers": [
      "JustSteveKing\\DataObjects\\Providers\\PackageServiceProvider"
    ],
    "aliases": [
      "JustSteveKing\\DataObjects\\Facades\\Hydrator"
    ]
  }
},

Тепер ми можемо бути впевнені, що наш пакет працює належним чином. Останнє, що нам потрібно зробити, це зосередитися на якості нашого коду. У більшості своїх пакунків я хочу переконатися, що стиль кодування та статичний аналіз працюють, щоб у мене був надійний пакет, якому я можу довіряти.Почнемо зі стилізації коду. Для цього ми встановимо пакет під назвою Laravel Pint, який є відносно новим:

declare(strict_types=1);
 
use JustSteveKing\DataObjects\Facades\Hydrator;
use JustSteveKing\DataObjects\Tests\Stubs\Test;
 
it('can create a data transfer object', function (string $string) {
    expect(
        Hydrator::fill(
            class: Test::class,
            properties: ['name' => $string],
        ),
    )->toBeInstanceOf(Test::class)->toArray()->toEqual(['name' => $string]);
})->with('strings');

Мені подобається використовувати PSR-12 для мого стилю коду, тому давайте створимо pint.json у корені нашого пакета, щоб переконатися, що ми налаштовуємо pint для запуску стандарт, який ми хочемо використовувати:

it('creates our data transfer object as we would expect', function (string $string) {
    $test = Hydrator::fill(
        class: Test::class,
        properties: ['name' => $string],
    );
 
    $reflection = new ReflectionClass(
        objectOrClass: $test,
    );
 
    expect(
        $reflection->getProperty(
            name: 'name',
        )->isReadOnly()
    )->toBeTrue()->and(
        $reflection->getProperty(
            name: 'name',
        )->isPrivate(),
    )->toBeTrue()->and(
        $reflection->getMethod(
            name: 'toArray',
        )->hasReturnType(),
    )->toBeTrue();
})->with('strings');

Тепер запустіть команду pint, щоб виправити будь-які проблеми зі стилем коду, які не відповідають PSR-12:

composer require --dev laravel/pint

Нарешті ми можемо встановити PHPStan, щоб ми могли перевірити статичний аналіз нашої кодової бази, щоб переконатися, що ми якомога суворіше та послідовніше до наших типів:

{
  "preset": "psr12"
}

Щоб налаштувати PHPStan, нам потрібно буде створити phpstan.neonу корені нашого пакета, щоб знати конфігурацію, яка використовується.

./vendor/bin/pint

Нарешті, ми можемо запустити PHPStan, щоб переконатися, що ми виглядаємо добре з точки зору типу.

composer require --dev phpstan/phpstan

Якщо все пройшло добре, ми маємо побачити повідомлення «[OK] Немає помилок».

parameters:
    level: 9

    paths:
        - src

Останній Кроки, яких я люблю виконувати для будь-якої збірки пакета, — це написати мій README і додати будь-які конкретні дії GitHub, які я можу запустити з пакетом.Я не буду додавати їх тут, тому що вони довгі та повні YAML. Однак ви можете самостійно переглянути репозиторій, щоб побачити, як вони були створені.

<
./vendor/bin/phpstan analyse

Чи створили ви будь-які пакети Laravel або PHP, про які хочете, щоб ми знали? Як ви підходите до розробки пакету? Повідомте нас у Twitter!

Comments

No comments yet
Sarah 3:34 PM

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.

Replies

Sarah 3:34 PM

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.

Sarah 3:34 PM

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.

Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Про автора CrazyBoy49z
WORK EXPERIENCE
Контакти
Ukraine, Lutsk
+380979856297