Tutorial de desenvolvimento
Passo a passo para criar um plugin Arqel do zero, do
composer inità submissão no marketplace.
Este tutorial constrói um exemplo concreto e útil: acme/stripe-card, um field-pack que renderiza o Stripe Elements Card como um Field do Arqel pronto para capturar payment methods. O exemplo cobre todas as etapas reais de um plugin de produção.
Por que este exemplo?
Stripe é universal — qualquer admin que toca em pagamentos eventualmente precisa de captura de cartão. O field demonstra:
- Field PHP com setters fluent (
publishableKey,captureMode,currency). - Component React companion via npm package separado.
- Integração com SDK externo (
@stripe/stripe-js) sem que o pacote PHP carregue assets JS direto. - Service provider registrando via
FieldRegistry. - Tests Pest + Vitest cobrindo PHP + React.
Antes de começar: arqel/* vs marketplace plugins
O monorepo arqel-dev/arqel mantém pacotes "core" (arqel-dev/core, arqel-dev/fields, arqel-dev/table, etc) que são maintained pelos devs do framework. Eles vivem em packages/* e são distribuídos via splitsh.
Plugins community (incluindo o exemplo deste tutorial) vivem em repositórios separados, mantidos por terceiros. Eles dependem de arqel-dev/* mas não têm acesso privilegiado — usam exatamente as mesmas APIs públicas que qualquer dev usa.
A linha:
| Aspecto | Pacote core (arqel-dev/*) | Plugin community |
|---|---|---|
| Repositório | Monorepo arqel-dev/arqel | Repo standalone do autor |
| Manutenção | Time Arqel | Autor (community) |
| Distribuição | Composer + npm via splitsh | Composer + npm direto pelo autor |
| Submissão ao marketplace | Não aplicável (já listado oficialmente) | Obrigatória |
| Security scan | Roda no CI interno | Roda no marketplace antes do publish |
| Versionamento | Sincronizado com framework releases | Independente, mas com compat.arqel |
Plugins não devem fork código de pacotes core — sempre estendam via APIs públicas (Field, Widget, Action, Resource).
Step 1 — Setup composer.json
Crie um repo novo acme/arqel-stripe-card e inicialize:
mkdir arqel-stripe-card && cd arqel-stripe-card
composer init --name=acme/stripe-card --type=arqel-pluginEdite composer.json para o estado canônico:
{
"name": "acme/stripe-card",
"description": "Stripe Card field for Arqel admin panels.",
"type": "arqel-plugin",
"license": "MIT",
"keywords": ["arqel", "plugin", "field", "stripe", "payments"],
"require": {
"php": "^8.3",
"arqel-dev/core": "^1.0",
"arqel-dev/fields": "^1.0"
},
"require-dev": {
"pestphp/pest": "^3.0",
"orchestra/testbench": "^9.0",
"phpstan/phpstan": "^1.11"
},
"autoload": {
"psr-4": {
"Acme\\StripeCard\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Acme\\StripeCard\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": ["Acme\\StripeCard\\StripeCardServiceProvider"]
},
"arqel": {
"plugin-type": "field-pack",
"category": "integrations",
"compat": {
"arqel": "^1.0"
},
"installation-instructions": "https://github.com/acme/arqel-stripe-card#installation"
}
},
"minimum-stability": "stable",
"prefer-stable": true
}Os pontos críticos:
type: arqel-plugin— sem isso oComposer\InstalledVersions::getInstalledPackagesByTypenão enxerga o plugin.extra.arqel.plugin-type— enum obrigatória (validada porPluginConventionValidator).extra.arqel.compat.arqel— constraint semver da versão do framework suportada.extra.laravel.providers— auto-discovery do Laravel registra seu provider sem o usuário precisar tocarconfig/app.php.
Step 2 — Service provider
Crie src/StripeCardServiceProvider.php:
<?php
declare(strict_types=1);
namespace Acme\StripeCard;
use Arqel\Fields\FieldRegistry;
use Illuminate\Support\ServiceProvider;
final class StripeCardServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/stripe-card.php', 'stripe-card');
}
public function boot(): void
{
FieldRegistry::register('stripe-card', StripeCardField::class);
$this->publishes([
__DIR__.'/../config/stripe-card.php' => config_path('stripe-card.php'),
], 'stripe-card-config');
}
}A FieldRegistry::register(name, class) é a API canônica de arqel-dev/fields para field types externos. O name ('stripe-card') é o que o Field expõe via Field::type() e o que o React side usa para resolver o componente.
E config/stripe-card.php:
<?php
declare(strict_types=1);
return [
'publishable_key' => env('STRIPE_PUBLISHABLE_KEY'),
'capture_mode' => env('STRIPE_CAPTURE_MODE', 'automatic'),
'currency' => env('STRIPE_CURRENCY', 'usd'),
];Step 3 — Field PHP
Crie src/StripeCardField.php:
<?php
declare(strict_types=1);
namespace Acme\StripeCard;
use Arqel\Fields\Field;
final class StripeCardField extends Field
{
protected string $type = 'stripe-card';
protected string $component = 'StripeCardInput';
public function publishableKey(string $key): self
{
$this->meta['publishableKey'] = $key;
return $this;
}
public function captureMode(string $mode): self
{
if (! in_array($mode, ['automatic', 'manual'], true)) {
throw new \InvalidArgumentException("Invalid capture mode: {$mode}");
}
$this->meta['captureMode'] = $mode;
return $this;
}
public function currency(string $code): self
{
$this->meta['currency'] = strtolower($code);
return $this;
}
public function toArray(): array
{
return [
...parent::toArray(),
'publishableKey' => $this->meta['publishableKey']
?? config('stripe-card.publishable_key'),
'captureMode' => $this->meta['captureMode']
?? config('stripe-card.capture_mode'),
'currency' => $this->meta['currency']
?? config('stripe-card.currency'),
];
}
}Convenções:
$typeé o identificador serializado (matchesFieldRegistry::registername).$componenté o nome do React component que vai resolver no client side.- Setters retornam
$thispara chaining fluent (idiomatic emFieldderivativas). toArray()é o que o Resource serializa para o Inertia prop.
Uso típico em um Resource:
StripeCardField::make('payment_method')
->publishableKey(config('services.stripe.publishable'))
->captureMode('manual')
->currency('EUR')
->required();Step 4 — NPM package companion
O lado React vive em pacote separado. Crie package.json:
{
"name": "@acme/arqel-stripe-fields",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"test": "vitest"
},
"keywords": ["arqel", "plugin", "field", "stripe"],
"peerDependencies": {
"@arqel-dev/types": "^1.0",
"@stripe/stripe-js": "^4.0",
"react": "^19.2"
},
"devDependencies": {
"tsup": "^8.0",
"vitest": "^2.0"
},
"arqel": {
"plugin-type": "field-pack"
}
}Agora src/StripeCardInput.tsx:
import type { FieldProps } from '@arqel-dev/types';
import { CardElement, Elements } from '@stripe/react-stripe-js';
import { loadStripe, type Stripe } from '@stripe/stripe-js';
import { useEffect, useMemo, useState } from 'react';
interface StripeCardInputProps extends FieldProps<string | null> {
publishableKey: string;
captureMode: 'automatic' | 'manual';
currency: string;
}
export function StripeCardInput(props: StripeCardInputProps) {
const [stripe, setStripe] = useState<Stripe | null>(null);
useEffect(() => {
loadStripe(props.publishableKey).then(setStripe);
}, [props.publishableKey]);
const options = useMemo(
() => ({ mode: props.captureMode, currency: props.currency }),
[props.captureMode, props.currency],
);
if (!stripe) {
return <div className="arqel-field-loading">Loading Stripe…</div>;
}
return (
<Elements stripe={stripe} options={options}>
<div className="arqel-field-stripe-card">
<label>{props.label}</label>
<CardElement
onChange={(event) => {
if (event.complete) {
props.onChange(event.elementType);
}
}}
/>
{props.error && <span className="arqel-field-error">{props.error}</span>}
</div>
</Elements>
);
}Crie src/index.ts com export agregador:
import { registerField } from '@arqel-dev/react';
import { StripeCardInput } from './StripeCardInput';
registerField('StripeCardInput', StripeCardInput);
export { StripeCardInput };registerField(name, component) é o equivalente JS do FieldRegistry::register PHP — o name precisa bater exatamente com $component do StripeCardField.
Step 5 — Tests
PHP test (Pest 3 + Orchestra Testbench) em tests/Unit/StripeCardFieldTest.php:
<?php
declare(strict_types=1);
use Acme\StripeCard\StripeCardField;
it('serializes publishable key', function (): void {
$field = StripeCardField::make('payment_method')
->publishableKey('pk_test_123')
->captureMode('manual')
->currency('EUR');
expect($field->toArray())
->toMatchArray([
'type' => 'stripe-card',
'publishableKey' => 'pk_test_123',
'captureMode' => 'manual',
'currency' => 'eur',
]);
});
it('rejects invalid capture mode', function (): void {
StripeCardField::make('pm')->captureMode('invalid');
})->throws(InvalidArgumentException::class);
it('falls back to config when key is missing', function (): void {
config(['stripe-card.publishable_key' => 'pk_from_config']);
$field = StripeCardField::make('pm');
expect($field->toArray()['publishableKey'])->toBe('pk_from_config');
});JS test (Vitest + Testing Library) em src/StripeCardInput.test.tsx:
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { StripeCardInput } from './StripeCardInput';
vi.mock('@stripe/stripe-js', () => ({
loadStripe: vi.fn().mockResolvedValue({ id: 'mock-stripe' }),
}));
describe('StripeCardInput', () => {
it('renders loading state initially', () => {
render(
<StripeCardInput
name="pm"
label="Payment method"
value={null}
onChange={() => {}}
publishableKey="pk_test"
captureMode="automatic"
currency="usd"
/>,
);
expect(screen.getByText(/Loading Stripe/)).toBeInTheDocument();
});
it('renders Elements after stripe loads', async () => {
render(
<StripeCardInput
name="pm"
label="Card"
value={null}
onChange={() => {}}
publishableKey="pk_test"
captureMode="manual"
currency="eur"
/>,
);
await waitFor(() => expect(screen.getByText('Card')).toBeInTheDocument());
});
});Rode os dois:
vendor/bin/pest
pnpm testCoverage targets seguindo ADR-008: PHP ≥90%, JS ≥80%.
Step 6 — README + screenshots
O README (README.md na raiz do repo) precisa cobrir mínimo:
- Badge de versão Packagist + npm + license MIT.
- Installation com snippet de
composer require+pnpm add. - Configuration mostrando
.enveconfig/stripe-card.php. - Usage example com snippet de Resource real.
- Screenshots (mínimo 2) — coloque em
docs/screen-1.png,docs/screen-2.pngpara que o submission form possa apontar para elas viaraw.githubusercontent.com. - Compatibility table com Arqel versions suportadas.
- License + DCO.
Step 7 — Submeter ao marketplace
Com Packagist + npm publicados e GitHub release tagueada (v0.1.0), abra arqel.dev/marketplace/submit e preencha o form. Por trás dos panos:
POST /api/marketplace/plugins/submit
Authorization: Bearer <publisher_sanctum_token>
{
"composer_package": "acme/stripe-card",
"npm_package": "@acme/arqel-stripe-fields",
"github_url": "https://github.com/acme/arqel-stripe-card",
"type": "field-pack",
"name": "Stripe Card Field",
"description": "Renderiza Stripe Elements Card como Field Arqel.",
"screenshots": [
"https://raw.githubusercontent.com/acme/arqel-stripe-card/main/docs/screen-1.png"
]
}A resposta 201 traz {plugin: {...}, checks: {...}}. A partir daí o pipeline descrito em Publicando plugins toma conta — auto-checks, security scan, manual review, published.
Iteração futura
Releases novas:
git tag v0.2.0
git push origin v0.2.0
# packagist auto-detecta via webhook; npm publish manualE para registrar a versão no marketplace:
POST /api/marketplace/plugins/acme-stripe-card/versions
{
"version": "0.2.0",
"changelog": "Fix em currency=EUR, added 3DS support."
}Anti-patterns comuns
- ❌ Não embarque assets JS no pacote PHP. Use companion npm package.
- ❌ Não require
arqel-dev/frameworkinteiro — declare apenasarqel-dev/core+ os pacotes que você de fato usa. - ❌ Não use
setMeta()raw ao invés de criar setters tipados — perde DX e quebra autocomplete. - ❌ Não chame
Stripe::setApiKey()global dentro do field — vaza estado entre requests. - ❌ Não force
arqel: ^1quando seu plugin precisa de feature^1.5— coloque^1.5para falhar nocomposer require, não em runtime.
Próximos passos
- Submeter pela primeira vez? Publicando plugins cobre o pipeline post-submit.
- Plugin pago? Pagamentos & licenças explica pricing + license keys.
- Quer evitar reprovação por security scan? Boas práticas de segurança lista patterns a evitar.