devitools/serendipity

The Hyperf missing component

Maintainers

👁 wilcorrea

Package info

github.com/devitools/serendipity

pkg:composer/devitools/serendipity

Statistics

Installs: 2 548

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

2.1.2 2026-02-27 18:09 UTC

Requires

Suggests

None

Provides

None

Conflicts

None

Replaces

None

MIT 4fe20bea63db4913e1db84267720c5aa8c4ffe23

phphyperf

This package is auto-updated.

Last update: 2026-06-27 18:51:05 UTC


README

👁 SonarQube Cloud

👁 Reliability Rating
👁 Security Rating
👁 Quality Gate Status
👁 Maintainability Rating

👁 Vulnerabilities
👁 Bugs
👁 Technical Debt
👁 Code Smells

👁 Coverage
👁 Duplicated Lines (%)
👁 Lines of Code

Serendipity

O componente que faltava no Hyperf

Serendipity é uma biblioteca PHP que estende o framework Hyperf com funcionalidades avançadas de Domain-Driven Design ( DDD), validação inteligente, serialização automática e infraestrutura robusta para aplicações de alta performance.

🍿 Visão Geral

Serendipity preenche as lacunas do ecossistema Hyperf, oferecendo uma camada de abstração poderosa que combina os melhores padrões de desenvolvimento com a performance assíncrona do Hyperf. Utilizando o Constructo como base, oferece metaprogramação avançada para resolver dependências e formatar dados de forma flexível.

Principais Características

  • 🏗️ Arquitetura DDD: Estrutura completa seguindo Domain-Driven Design
  • ⚡ Assíncrono por Padrão: Totalmente compatível com corrotinas do Hyperf
  • 🔍 Validação Inteligente: Sistema de validação baseado em atributos e regras
  • 📊 Serialização Automática: Conversão inteligente de entidades para diferentes formatos
  • 🎯 Type Safety: Tipagem forte com suporte a generics
  • 🧪 Testabilidade: Ferramentas completas para testes unitários e de integração
  • 📈 Observabilidade: Logging estruturado e monitoramento integrado

🚀 Instalação

Pré-requisitos

  • PHP 8.3+
  • Extensões: ds, json, mongodb, pdo, swoole
  • Hyperf 3.1+
  • Docker 25+ (para desenvolvimento)
  • Docker Compose 2.23+

Instalação via Composer

composer require devitools/serendipity

Configuração Básica

Registre o ConfigProvider no seu config/config.php:

<?php

return [
 'providers' => [
 Serendipity\ConfigProvider::class,
 ],
];

Configure as dependências em config/autoload/dependencies.php:

<?php

return [
 \Constructo\Contract\Reflect\TypesFactory::class => 
 \Serendipity\Hyperf\Support\HyperfTypesFactory::class,
 \Constructo\Contract\Reflect\SpecsFactory::class => 
 \Serendipity\Hyperf\Support\HyperfSpecsFactory::class,
];

🎯 Funcionalidades Principais

Entidades com Tipagem Forte

Crie entidades robustas com validação automática e serialização inteligente:

<?php

use Constructo\Support\Reflective\Attribute\Managed;
use Constructo\Support\Reflective\Attribute\Pattern;
use Constructo\Type\Timestamp;

class Game extends GameCommand
{
 public function __construct(
 #[Managed('id')]
 public readonly string $id,
 #[Managed('timestamp')]
 public readonly Timestamp $createdAt,
 #[Managed('timestamp')]
 public readonly Timestamp $updatedAt,
 #[Pattern('/^[a-zA-Z]{1,255}$/')]
 string $name,
 #[Pattern]
 string $slug,
 Timestamp $publishedAt,
 array $data,
 FeatureCollection $features,
 ) {
 parent::__construct(
 name: $name,
 slug: $slug,
 publishedAt: $publishedAt,
 data: $data,
 features: $features,
 );
 }
}

Coleções Tipadas

Trabalhe com coleções type-safe que garantem integridade dos dados:

<?php

use Constructo\Type\Collection;

/**
 * @extends Collection<Feature>
 */
class FeatureCollection extends Collection
{
 public function current(): Feature
 {
 return $this->validate($this->datum());
 }

 protected function validate(mixed $datum): Feature
 {
 return ($datum instanceof Feature)
 ? $datum
 : throw $this->exception(Feature::class, $datum);
 }
}

Validação de Input Inteligente

Sistema de validação integrado com Hyperf que suporta regras complexas:

<?php

use Serendipity\Presentation\Input;

final class HealthInput extends Input
{
 public function rules(): array
 {
 return [
 'message' => 'sometimes|string|max:255',
 'level' => 'required|in:debug,info,warning,error',
 'metadata' => 'array',
 ];
 }
}

Actions com Injeção de Dependência

Crie actions limpas com injeção automática de dependências:

<?php

readonly class HealthAction
{
 public function __invoke(HealthInput $input): array
 {
 return [
 'method' => $input->getMethod(),
 'message' => $input->value('message', 'Sistema funcionando perfeitamente!'),
 'timestamp' => time(),
 'status' => 'healthy'
 ];
 }
}

🏗️ Arquitetura de Projeto com Serendipity

Estrutura recomendada para projetos que utilizam Serendipity, baseada em projetos reais em produção:

/
├── app/ # Código fonte da aplicação
│ ├── Application/ # Casos de uso da aplicação
│ │ ├── Exception/ # Exceções de aplicação
│ │ └── Service/ # Serviços de aplicação
│ ├── Domain/ # Lógica de negócio pura
│ │ ├── Entity/ # Entidades do domínio
│ │ ├── Enum/ # Enums do domínio
│ │ ├── Provider/ # Provedores de domínio
│ │ ├── Repository/ # Contratos de repositório
│ │ ├── Service/ # Serviços de domínio
│ │ ├── Support/ # Utilitários do domínio
│ │ └── Validator/ # Validadores de negócio
│ ├── Infrastructure/ # Implementações de infraestrutura
│ │ ├── Exception/ # Exceções de infraestrutura
│ │ ├── Parser/ # Parsers de dados
│ │ ├── Repository/ # Implementações de repositório
│ │ ├── Service/ # Serviços de infraestrutura
│ │ ├── Support/ # Utilitários de infraestrutura
│ │ └── Validator/ # Validadores de infraestrutura
│ └── Presentation/ # Camada de apresentação
│ ├── Action/ # Controllers/Actions
│ ├── Input/ # Validação de entrada
│ └── Service/ # Serviços de apresentação
├── bin/ # Scripts executáveis
│ ├── hyperf.php # Script principal do Hyperf
│ └── phpunit.php # Script de testes
├── compose.yml # Configuração principal do Docker Compose
├── composer.json # Dependências do Composer
├── composer.lock # Lock das dependências
├── config/ # Configurações da aplicação
│ └── autoload/ # Configurações carregadas automaticamente
├── deptrac.yaml # Configuração de análise de dependências
├── Dockerfile # Configuração do Docker
├── LICENSE # Licença do projeto
├── makefile # Comandos de desenvolvimento
├── migrations/ # Migrações do banco de dados
├── phpcs.xml # Configuração do PHP CodeSniffer
├── phpmd.xml # Configuração do PHP Mess Detector
├── phpstan.neon # Configuração do PHPStan
├── phpunit.xml # Configuração do PHPUnit
├── psalm.xml # Configuração do Psalm
├── README.md # Documentação principal
├── rector.php # Configuração do Rector
├── runtime/ # Arquivos temporários e cache
├── sonar-project.properties # Configuração do SonarQube
├── storage/ # Armazenamento local
└── tests/ # Testes automatizados
 ├── Application/ # Testes de aplicação
 ├── Domain/ # Testes de domínio
 ├── Infrastructure/ # Testes de infraestrutura
 └── Presentation/ # Testes de apresentação

Organização das Camadas

Application Layer - Casos de uso e orquestração

  • Service/: Coordenam operações entre domínio e infraestrutura
  • Exception/: Exceções específicas da camada de aplicação

Domain Layer - Lógica de negócio pura

  • Entity/: Entidades principais do negócio
  • Enum/: Enumerações e constantes do domínio
  • Repository/: Interfaces para persistência
  • Service/: Regras de negócio complexas
  • Validator/: Validações de regras de negócio

Infrastructure Layer - Implementações técnicas

  • Repository/: Implementações concretas dos repositórios
  • Service/: Integrações com APIs externas
  • Parser/: Processamento e transformação de dados
  • Support/: Utilitários técnicos

Presentation Layer - Interface com o mundo externo

  • Action/: Endpoints HTTP e handlers
  • Input/: Validação e sanitização de entrada
  • Service/: Formatação de resposta

Exemplo de Estrutura de Action

<?php

namespace App\Presentation\Action;

use App\Presentation\Input\ProcessLeadInput;
use App\Application\Service\LeadProcessorService;

readonly class ProcessLeadAction
{
 public function __construct(
 private LeadProcessorService $processor
 ) {}

 public function __invoke(ProcessLeadInput $input): array
 {
 $result = $this->processor->process($input->validated());
 
 return [
 'success' => true,
 'data' => $result->toArray(),
 ];
 }
}

📋 Exemplos Práticos

Entidade User com Validação

<?php

namespace App\Domain\Entity;

use Constructo\Support\Reflective\Attribute\Managed;
use Constructo\Support\Reflective\Attribute\Pattern;
use DateTime;

readonly class User
{
 public function __construct(
 #[Managed('id')]
 public int $id,
 #[Pattern('/^[a-zA-Z\s]{2,100}$/')]
 public string $name,
 public DateTime $birthDate,
 public bool $isActive = true,
 public array $tags = [],
 ) {
 }

 public function getAge(): int
 {
 return $this->birthDate->diff(new DateTime())->y;
 }

 public function isAdult(): bool
 {
 return $this->getAge() >= 18;
 }

 public function addTag(string $tag): array
 {
 return [...$this->tags, $tag];
 }
}

Input de Validação para User

<?php

namespace App\Presentation\Input;

use Serendipity\Presentation\Input;

final class CreateUserInput extends Input
{
 public function rules(): array
 {
 return [
 'name' => 'required|string|min:2|max:100|regex:/^[a-zA-Z\s]+$/',
 'birth_date' => 'required|date|before:today',
 'is_active' => 'sometimes|boolean',
 'tags' => 'sometimes|array',
 'tags.*' => 'string|max:50',
 'email' => 'required|email|unique:users,email',
 'password' => 'required|string|min:8|confirmed',
 ];
 }

 public function messages(): array
 {
 return [
 'name.regex' => 'O nome deve conter apenas letras e espaços',
 'birth_date.before' => 'A data de nascimento deve ser anterior a hoje',
 'email.unique' => 'Este email já está em uso',
 'password.confirmed' => 'A confirmação da senha não confere',
 ];
 }
}

Action para Criação de User

<?php

namespace App\Presentation\Action;

use App\Domain\Entity\User;
use App\Presentation\Input\CreateUserInput;
use App\Domain\Service\UserService;
use DateTime;
use Psr\Log\LoggerInterface;

readonly class CreateUserAction
{
 public function __construct(
 private UserService $userService,
 private LoggerInterface $logger
 ) {}

 public function __invoke(CreateUserInput $input): array
 {
 $userData = $input->validated();
 
 $user = new User(
 id: 0, // Será preenchido pelo banco
 name: $userData['name'],
 birthDate: new DateTime($userData['birth_date']),
 isActive: $userData['is_active'] ?? true,
 tags: $userData['tags'] ?? []
 );

 $savedUser = $this->userService->create($user, $userData['password']);

 $this->logger->info('Usuário criado com sucesso', [
 'user_id' => $savedUser->id,
 'name' => $savedUser->name,
 'is_adult' => $savedUser->isAdult(),
 ]);

 return [
 'success' => true,
 'user' => [
 'id' => $savedUser->id,
 'name' => $savedUser->name,
 'age' => $savedUser->getAge(),
 'is_adult' => $savedUser->isAdult(),
 'is_active' => $savedUser->isActive,
 'tags' => $savedUser->tags,
 ],
 ];
 }
}

Serviço de Domínio para User

<?php

namespace App\Domain\Service;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Infrastructure\Service\PasswordHashService;

readonly class UserService
{
 public function __construct(
 private UserRepositoryInterface $userRepository,
 private PasswordHashService $passwordService
 ) {}

 public function create(User $user, string $password): User
 {
 // Validações de negócio
 if (!$user->isAdult()) {
 throw new \DomainException('Usuário deve ser maior de idade');
 }

 if (count($user->tags) > 10) {
 throw new \DomainException('Usuário não pode ter mais de 10 tags');
 }

 // Hash da senha
 $hashedPassword = $this->passwordService->hash($password);

 // Persistir no banco
 return $this->userRepository->save($user, $hashedPassword);
 }

 public function updateTags(int $userId, array $newTags): User
 {
 $user = $this->userRepository->findById($userId);
 
 if (!$user) {
 throw new \DomainException('Usuário não encontrado');
 }

 if (count($newTags) > 10) {
 throw new \DomainException('Usuário não pode ter mais de 10 tags');
 }

 return $this->userRepository->updateTags($userId, $newTags);
 }
}

Repositório de User

<?php

namespace App\Domain\Repository;

use App\Domain\Entity\User;

interface UserRepositoryInterface
{
 public function save(User $user, string $hashedPassword): User;
 
 public function findById(int $id): ?User;
 
 public function findByEmail(string $email): ?User;
 
 public function updateTags(int $userId, array $tags): User;
 
 public function findActiveUsers(): array;
 
 public function findUsersByTag(string $tag): array;
}

Implementação do Repositório

<?php

namespace App\Infrastructure\Repository;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use Hyperf\Database\ConnectionInterface;
use DateTime;

readonly class UserRepository implements UserRepositoryInterface
{
 public function __construct(
 private ConnectionInterface $connection
 ) {}

 public function save(User $user, string $hashedPassword): User
 {
 $id = $this->connection->table('users')->insertGetId([
 'name' => $user->name,
 'email' => $user->email ?? '',
 'password' => $hashedPassword,
 'birth_date' => $user->birthDate->format('Y-m-d'),
 'is_active' => $user->isActive,
 'tags' => json_encode($user->tags),
 'created_at' => now(),
 'updated_at' => now(),
 ]);

 return new User(
 id: $id,
 name: $user->name,
 birthDate: $user->birthDate,
 isActive: $user->isActive,
 tags: $user->tags
 );
 }

 public function findById(int $id): ?User
 {
 $userData = $this->connection
 ->table('users')
 ->where('id', $id)
 ->first();

 if (!$userData) {
 return null;
 }

 return new User(
 id: $userData->id,
 name: $userData->name,
 birthDate: new DateTime($userData->birth_date),
 isActive: (bool) $userData->is_active,
 tags: json_decode($userData->tags, true) ?? []
 );
 }

 public function findByEmail(string $email): ?User
 {
 $userData = $this->connection
 ->table('users')
 ->where('email', $email)
 ->first();

 if (!$userData) {
 return null;
 }

 return new User(
 id: $userData->id,
 name: $userData->name,
 birthDate: new DateTime($userData->birth_date),
 isActive: (bool) $userData->is_active,
 tags: json_decode($userData->tags, true) ?? []
 );
 }

 public function updateTags(int $userId, array $tags): User
 {
 $this->connection
 ->table('users')
 ->where('id', $userId)
 ->update([
 'tags' => json_encode($tags),
 'updated_at' => now(),
 ]);

 return $this->findById($userId);
 }

 public function findActiveUsers(): array
 {
 $users = $this->connection
 ->table('users')
 ->where('is_active', true)
 ->get();

 return $users->map(fn($userData) => new User(
 id: $userData->id,
 name: $userData->name,
 birthDate: new DateTime($userData->birth_date),
 isActive: true,
 tags: json_decode($userData->tags, true) ?? []
 ))->toArray();
 }

 public function findUsersByTag(string $tag): array
 {
 $users = $this->connection
 ->table('users')
 ->whereJsonContains('tags', $tag)
 ->get();

 return $users->map(fn($userData) => new User(
 id: $userData->id,
 name: $userData->name,
 birthDate: new DateTime($userData->birth_date),
 isActive: (bool) $userData->is_active,
 tags: json_decode($userData->tags, true) ?? []
 ))->toArray();
 }
}

Coleção Tipada de Users

<?php

namespace App\Domain\Collection;

use Constructo\Type\Collection;
use App\Domain\Entity\User;

/**
 * @extends Collection<User>
 */
class UserCollection extends Collection
{
 public function current(): User
 {
 return $this->validate($this->datum());
 }

 protected function validate(mixed $datum): User
 {
 return ($datum instanceof User)
 ? $datum
 : throw $this->exception(User::class, $datum);
 }

 public function getActiveUsers(): UserCollection
 {
 return new self(
 array_filter($this->items, fn(User $user) => $user->isActive)
 );
 }

 public function getAdultUsers(): UserCollection
 {
 return new self(
 array_filter($this->items, fn(User $user) => $user->isAdult())
 );
 }

 public function getUsersByTag(string $tag): UserCollection
 {
 return new self(
 array_filter($this->items, fn(User $user) => in_array($tag, $user->tags))
 );
 }

 public function getAverageAge(): float
 {
 if ($this->count() === 0) {
 return 0;
 }

 $totalAge = array_sum(
 array_map(fn(User $user) => $user->getAge(), $this->items)
 );

 return $totalAge / $this->count();
 }
}

🧪 Testes

Serendipity fornece ferramentas robustas para testes:

<?php

use Serendipity\Testing\TestCase;
use App\Domain\Entity\User;
use App\Presentation\Input\CreateUserInput;
use App\Presentation\Action\CreateUserAction;
use DateTime;

class CreateUserActionTest extends TestCase
{
 public function testCreateUserSuccess(): void
 {
 $input = new CreateUserInput([
 'name' => 'João Silva',
 'birth_date' => '1990-05-15',
 'email' => 'joao@example.com',
 'password' => 'senha123456',
 'password_confirmation' => 'senha123456',
 'is_active' => true,
 'tags' => ['desenvolvedor', 'php'],
 ]);

 $action = $this->container()->get(CreateUserAction::class);
 $result = $action($input);

 $this->assertTrue($result['success']);
 $this->assertArrayHasKey('user', $result);
 $this->assertEquals('João Silva', $result['user']['name']);
 $this->assertTrue($result['user']['is_adult']);
 $this->assertTrue($result['user']['is_active']);
 $this->assertContains('desenvolvedor', $result['user']['tags']);
 }

 public function testCreateUserValidationFails(): void
 {
 $this->expectException(\Hyperf\Validation\ValidationException::class);

 $input = new CreateUserInput([
 'name' => '', // Nome vazio
 'birth_date' => '2020-01-01', // Menor de idade
 'email' => 'email-invalido', // Email inválido
 'password' => '123', // Senha muito curta
 ]);

 $input->validated();
 }

 public function testUserEntityMethods(): void
 {
 $user = new User(
 id: 1,
 name: 'Maria Santos',
 birthDate: new DateTime('1985-03-20'),
 isActive: true,
 tags: ['designer', 'ui-ux']
 );

 $this->assertEquals(39, $user->getAge()); // Assumindo 2024
 $this->assertTrue($user->isAdult());
 $this->assertEquals(['designer', 'ui-ux', 'frontend'], $user->addTag('frontend'));
 }
}

class UserServiceTest extends TestCase
{
 public function testCreateUserWithBusinessRules(): void
 {
 $userService = $this->container()->get(\App\Domain\Service\UserService::class);
 
 $user = new User(
 id: 0,
 name: 'Pedro Costa',
 birthDate: new DateTime('1992-08-10'),
 isActive: true,
 tags: ['backend']
 );

 $result = $userService->create($user, 'senhaSegura123');

 $this->assertInstanceOf(User::class, $result);
 $this->assertGreaterThan(0, $result->id);
 }

 public function testCreateMinorUserFails(): void
 {
 $this->expectException(\DomainException::class);
 $this->expectExceptionMessage('Usuário deve ser maior de idade');

 $userService = $this->container()->get(\App\Domain\Service\UserService::class);
 
 $minorUser = new User(
 id: 0,
 name: 'Criança',
 birthDate: new DateTime('2020-01-01'),
 isActive: true,
 tags: []
 );

 $userService->create($minorUser, 'senha123');
 }

 public function testUpdateTagsSuccess(): void
 {
 $userService = $this->container()->get(\App\Domain\Service\UserService::class);
 
 // Mock do usuário existente
 $existingUser = new User(
 id: 1,
 name: 'Ana Silva',
 birthDate: new DateTime('1988-12-05'),
 isActive: true,
 tags: ['old-tag']
 );

 $newTags = ['new-tag', 'another-tag'];
 $result = $userService->updateTags(1, $newTags);

 $this->assertInstanceOf(User::class, $result);
 $this->assertEquals($newTags, $result->tags);
 }
}

class UserCollectionTest extends TestCase
{
 public function testUserCollectionFilters(): void
 {
 $users = [
 new User(1, 'João', new DateTime('1990-01-01'), true, ['php']),
 new User(2, 'Maria', new DateTime('2010-01-01'), true, ['js']), // Menor
 new User(3, 'Pedro', new DateTime('1985-01-01'), false, ['python']), // Inativo
 new User(4, 'Ana', new DateTime('1992-01-01'), true, ['php', 'laravel']),
 ];

 $collection = new \App\Domain\Collection\UserCollection($users);

 // Teste filtro de usuários ativos
 $activeUsers = $collection->getActiveUsers();
 $this->assertCount(3, $activeUsers);

 // Teste filtro de usuários adultos
 $adultUsers = $collection->getAdultUsers();
 $this->assertCount(3, $adultUsers);

 // Teste filtro por tag
 $phpUsers = $collection->getUsersByTag('php');
 $this->assertCount(2, $phpUsers);

 // Teste média de idade
 $averageAge = $collection->getAverageAge();
 $this->assertGreaterThan(0, $averageAge);
 }

 public function testEmptyCollectionAverageAge(): void
 {
 $collection = new \App\Domain\Collection\UserCollection([]);
 $this->assertEquals(0, $collection->getAverageAge());
 }
}

⚡ Performance e Observabilidade

Logging Estruturado

<?php

$this->logger->info('Lead processado com sucesso', [
 'lead_id' => $leadId,
 'source' => $source,
 'processing_time_ms' => $processingTime,
 'memory_usage' => memory_get_usage(true),
]);

Métricas e Monitoramento

<?php

// Integração com sistemas de métricas
use Hyperf\Context\Context;

Context::set('metrics.processing_start', microtime(true));
$result = $this->processLead($input);
$duration = microtime(true) - Context::get('metrics.processing_start');

$this->logger->info('Métrica de performance', [
 'operation' => 'process_lead',
 'duration_ms' => round($duration * 1000, 2),
 'success' => $result->isSuccess(),
]);

🔧 Configuração Avançada

Schema e Especificações

Configure schemas personalizados em config/autoload/schema.php:

<?php

return [
 'specs' => [
 'lead' => [
 'id' => 'string',
 'name' => 'string',
 'email' => 'email',
 'phone' => 'string',
 'created_at' => 'timestamp',
 ],
 'quote' => [
 'id' => 'string',
 'lead_id' => 'string',
 'amount' => 'decimal',
 'status' => 'enum:pending,approved,rejected',
 ],
 ],
];

Middlewares Personalizados

<?php

use Serendipity\Hyperf\Middleware\AbstractMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;

class LeadValidationMiddleware extends AbstractMiddleware
{
 public function process(
 ServerRequestInterface $request, 
 RequestHandlerInterface $handler
 ): ResponseInterface {
 // Validação específica de leads
 $body = $request->getParsedBody();
 
 if (isset($body['email']) && !filter_var($body['email'], FILTER_VALIDATE_EMAIL)) {
 throw new InvalidArgumentException('Email inválido');
 }
 
 return $handler->handle($request);
 }
}

📚 Comandos CLI

Serendipity inclui comandos úteis para desenvolvimento:

# Gerar regras de validação
php bin/hyperf.php gen:rules LeadRules

# Executar health check via CLI
php bin/hyperf.php health:check

# Processar leads em lote
php bin/hyperf.php lead:process-batch

# Limpar caches
php bin/hyperf.php cache:clear

🤝 Contribuindo

Fork o projeto, crie uma branch para sua feature, commit suas mudanças, push para a branch e abra um Pull Request.

Padrões de Desenvolvimento

  • Siga PSR-12 para código PHP
  • Use tipagem forte sempre que possível
  • Implemente testes para novas funcionalidades
  • Documente mudanças no README

📄 Licença

Este projeto está licenciado sob a Licença MIT - veja o arquivo LICENSE para detalhes.

🔗 Links Relacionados

Serendipity - Descobrindo o potencial completo do Hyperf através de componentes elegantes e poderosos.