Note: Shields.io caches responses. New packages may show "invalid response data" for a few minutes after first Packagist publish.
php artisan boost:add-skill laravel-gtm/snowflake-sdk
Save this content to: .cursor/rules/laravel-package.md
### Saloon API SDK template (this repo)
- **Pint**: This package ships `laravel/pint` in `require-dev` and uses `composer lint` / `composer format` (`vendor/bin/pint`). CI installs Composer dependencies and runs Pint from `vendor/bin`. The "global Pint" guidance below is an alternative; either approach is valid.
# Laravel Package Rules
### Structure
```
package-name/
├── .github/
│ ├── scripts/parse-changelog.sh
│ └── workflows/
│ ├── tests.yml
│ ├── pint.yml
│ └── release.yml
├── config/package-name.php
├── database/migrations/*.php.stub
├── docs/
│ ├── index.md
│ ├── installation.md
│ ├── configuration.md
│ ├── usage.md
│ ├── api-reference.md
│ └── troubleshooting.md
├── src/
│ ├── Commands/
│ ├── Concerns/ # Traits
│ ├── Contracts/ # Interfaces
│ ├── Models/
│ └── PackageServiceProvider.php
├── tests/
│ ├── TestCase.php
│ └── Fixtures/
├── composer.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── phpunit.xml.dist
└── pint.json
```
### README Badges
```markdown
[](https://packagist.org/packages/vendor/package)
[](https://github.com/vendor/package/actions/workflows/tests.yml)
[](https://packagist.org/packages/vendor/package)
[](https://packagist.org/packages/vendor/package)
[](https://packagist.org/packages/vendor/package)
```
**Note**: Shields.io caches responses. New packages may show "invalid response data" for a few minutes after first Packagist publish.
### composer.json
```json
{
"name": "vendor/package-name",
"require": {
"php": "^8.2",
"illuminate/contracts": "^10.0|^11.0|^12.24",
"illuminate/support": "^10.0|^11.0|^12.24"
},
"require-dev": {
"orchestra/testbench": "^8.21|^9.6|^10.5",
"pestphp/pest": "^2.0|^3.0|^4.0"
},
"autoload": {
"psr-4": { "Vendor\\Package\\": "src/" },
"files": ["src/helpers.php"]
},
"extra": {
"laravel": {
"providers": ["Vendor\\Package\\PackageServiceProvider"]
}
},
"scripts": {
"test": "pest"
}
}
```
**Version Matrix**:
|Laravel|PHP|Testbench|
|-|-|-|
|10.x|8.1-8.3|8.x|
|11.x|8.2-8.4|9.x|
|12.24+|8.2-8.5|10.x|
**Rules**:
- Require `illuminate/*` packages, NEVER `laravel/framework`
- Use Pest for testing (not PHPUnit)
- Never install pint locally—use global pint (`composer global require laravel/pint`)
- Include `pint.json` with `{"preset": "laravel"}`
### Service Provider (Vanilla)
```php
class PackageServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/package.php', 'package');
$this->app->singleton(PackageService::class);
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/package.php' => config_path('package.php'),
], 'package-config');
$this->publishesMigrations([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'package-migrations');
$this->commands([PackageCommand::class]);
}
if (config('package.route.enabled', true)) {
$this->registerRoutes();
}
}
protected function registerRoutes(): void
{
Route::prefix(config('package.route.prefix', 'api'))
->middleware(config('package.route.middleware', []))
->group(fn() => $this->loadRoutesFrom(__DIR__.'/../routes/api.php'));
}
}
```
### Traits for Eloquent Models
```php
// src/Concerns/HasFeature.php
namespace Vendor\Package\Concerns;
trait HasFeature
{
public function initializeHasFeature(): void
{
// Called on model boot
}
public function feature(): MorphMany
{
$model = config('package.model', \Vendor\Package\Models\Feature::class);
return $this->morphMany($model, 'featureable');
}
public function addFeature(BackedEnum $type, mixed $data = null): Model
{
return $this->feature()->create([
'type' => $type->value,
'data' => $data,
]);
}
public function scopeWithFeature($query, BackedEnum $type)
{
return $query->whereHas('feature', fn($q) => $q->where('type', $type->value));
}
}
```
**Usage**: `use Vendor\Package\Concerns\HasFeature;`
### Helper Functions
```php
// src/helpers.php
if (!function_exists('package')) {
function package(string $source, string $path): UrlBuilder
{
return new UrlBuilder($source, $path);
}
}
```
### Fluent URL/API Builders
```php
class UrlBuilder implements Htmlable, Stringable
{
protected array $options = [];
public function __construct(protected string $source, protected string $path) {}
public function width(int $w): static { $this->options['w'] = $w; return $this; }
public function height(int $h): static { $this->options['h'] = $h; return $this; }
public function format(string $f): static { $this->options['f'] = $f; return $this; }
public function webp(): static { return $this->format('webp'); }
public function url(): string
{
$opts = collect($this->options)->map(fn($v, $k) => "$k=$v")->implode(',');
return "/$opts/{$this->source}/{$this->path}";
}
public function toHtml(): string { return $this->url(); }
public function __toString(): string { return $this->url(); }
}
```
**Blade usage**: `<img src="{{ package('images', 'photo.jpg')->width(400)->webp() }}">`
### Configuration
```php
// config/package.php
return [
'route' => [
'enabled' => true,
'prefix' => null,
'middleware' => [],
'name' => 'package.show',
],
'model' => \Vendor\Package\Models\Item::class,
'rate_limit' => [
'enabled' => true,
'max_attempts' => 10,
],
'cache' => [
'max_age' => 2592000, // 30 days
's_maxage' => 2592000,
'immutable' => true,
],
];
```
### Migration Stubs
Use `.php.stub` for publishable migrations:
```php
// database/migrations/create_items_table.php.stub
return new class extends Migration
{
public function up(): void
{
Schema::create(config('package.table', 'items'), function (Blueprint $table) {
$table->id();
$table->morphs('itemable');
$table->string('type');
$table->json('data')->nullable();
$table->timestamps();
$table->index(['itemable_id', 'itemable_type']);
});
}
};
```
### Artisan Commands
```php
class PackageCommand extends Command
{
protected $signature = 'package:process
{--list : List items without processing}
{--pretend : Show what would be processed}
{--dry-run : Alias for --pretend}';
public function handle(): int
{
if ($this->option('list')) {
return $this->listItems();
}
$pretend = $this->option('pretend') || $this->option('dry-run');
foreach ($this->discoverItems() as $item) {
if ($pretend) {
$this->line("Would process: {$item->name}");
continue;
}
$this->processItem($item);
$this->info("Processed: {$item->name}");
}
return self::SUCCESS;
}
}
```
### Testing (Pest + Testbench)
```php
// tests/TestCase.php
abstract class TestCase extends \Orchestra\Testbench\TestCase
{
protected function getPackageProviders($app): array
{
return [PackageServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('app.timezone', 'UTC');
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
}
```
```php
// tests/FeatureTest.php
uses(TestCase::class);
it('processes items correctly', function () {
$item = Item::factory()->create();
$result = $item->process();
expect($result)->toBeTrue();
});
```
**Sharding** (different from `--parallel`): Split tests across CI matrix jobs:
```bash
vendor/bin/pest --shard=1/3 # Run in separate CI jobs
vendor/bin/pest --shard=2/3
vendor/bin/pest --shard=3/3
```
Use sharding for large test suites; `--parallel` runs tests concurrently within a single process.
### GitHub Actions
Use `/setup-laravel-package-workflows` to set up:
- **tests.yml**: Matrix testing across PHP/Laravel versions
- **style.yml**: Pint auto-fix on push
Use `/setup-release-workflow` to set up release automation.
**Testbench resolution**: Don't specify testbench in the workflow—composer resolves the correct version based on the Laravel constraint.
**prefer-lowest failures**: Bump minimum version constraint if an old dependency lacks a needed feature. Never skip prefer-lowest—it catches accidental dependency on newer features.
### CHANGELOG.md (Keep a Changelog)
```markdown
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Initial release
[Unreleased]: https://github.com/vendor/package/compare/HEAD
```
### Path Validation (Security)
```php
class PathValidator
{
public static function directories(array $allowed): Closure
{
return fn(string $path) => Str::startsWith($path, $allowed);
}
public static function extensions(array $allowed): Closure
{
return fn(string $path) => in_array(pathinfo($path, PATHINFO_EXTENSION), $allowed);
}
}
// In service
public function resolve(string $path): void
{
if (str_contains($path, '..')) {
throw new InvalidPathException('Directory traversal not allowed');
}
$validator = config('package.validator');
if ($validator && !$validator($path)) {
throw new InvalidPathException('Path validation failed');
}
}
```
### Common Patterns
**Interface-based discovery**:
```php
interface Processable {
public static function process(): void;
public static function shouldProcess(CallbackEvent $event): CallbackEvent|bool;
}
```
**Enum-based types**:
```php
// config: 'types' => ['user' => UserType::class]
class TypeRegistry {
public static function getAlias(BackedEnum $enum): string { }
public static function getClass(string $alias): string { }
}
```
**Prune/cleanup commands**:
```php
interface Pruneable {
public function prune(): ?PruneConfig;
}
class PruneConfig {
public function __construct(
public ?Carbon $before = null,
public ?int $keep = null,
) {}
}
```
### Versioning (SemVer)
- **MAJOR**: Breaking changes, drop Laravel/PHP versions
- **MINOR**: New features, backward-compatible
- **PATCH**: Bug fixes only
### Common Mistakes
|Mistake|Fix|
|-|-|
|`use App\Models\User`|`config('package.user_model')`|
|`env('KEY')` in code|`config('package.key')`|
|No CHANGELOG.md|Keep a Changelog format|
|Missing --pretend flag|Add for destructive commands|
laravel/pint in require-dev and uses composer lint / composer format (vendor/bin/pint). CI installs Composer dependencies and runs Pint from vendor/bin. The "global Pint" guidance below is an alternative; either approach is valid.package-name/
├── .github/
│ ├── scripts/parse-changelog.sh
│ └── workflows/
│ ├── tests.yml
│ ├── pint.yml
│ └── release.yml
├── config/package-name.php
├── database/migrations/*.php.stub
├── docs/
│ ├── index.md
│ ├── installation.md
│ ├── configuration.md
│ ├── usage.md
│ ├── api-reference.md
│ └── troubleshooting.md
├── src/
│ ├── Commands/
│ ├── Concerns/ # Traits
│ ├── Contracts/ # Interfaces
│ ├── Models/
│ └── PackageServiceProvider.php
├── tests/
│ ├── TestCase.php
│ └── Fixtures/
├── composer.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── phpunit.xml.dist
└── pint.json
[](https://packagist.org/packages/vendor/package)
[](https://github.com/vendor/package/actions/workflows/tests.yml)
[](https://packagist.org/packages/vendor/package)
[](https://packagist.org/packages/vendor/package)
[](https://packagist.org/packages/vendor/package)
Note: Shields.io caches responses. New packages may show "invalid response data" for a few minutes after first Packagist publish.
{
"name": "vendor/package-name",
"require": {
"php": "^8.2",
"illuminate/contracts": "^10.0|^11.0|^12.24",
"illuminate/support": "^10.0|^11.0|^12.24"
},
"require-dev": {
"orchestra/testbench": "^8.21|^9.6|^10.5",
"pestphp/pest": "^2.0|^3.0|^4.0"
},
"autoload": {
"psr-4": { "Vendor\\Package\\": "src/" },
"files": ["src/helpers.php"]
},
"extra": {
"laravel": {
"providers": ["Vendor\\Package\\PackageServiceProvider"]
}
},
"scripts": {
"test": "pest"
}
}
Version Matrix:
| Laravel | PHP | Testbench |
|---|---|---|
| 10.x | 8.1-8.3 | 8.x |
| 11.x | 8.2-8.4 | 9.x |
| 12.24+ | 8.2-8.5 | 10.x |
| Rules: |
illuminate/* packages, NEVER laravel/frameworkcomposer global require laravel/pint)pint.json with {"preset": "laravel"}class PackageServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/package.php', 'package');
$this->app->singleton(PackageService::class);
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/package.php' => config_path('package.php'),
], 'package-config');
$this->publishesMigrations([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'package-migrations');
$this->commands([PackageCommand::class]);
}
if (config('package.route.enabled', true)) {
$this->registerRoutes();
}
}
protected function registerRoutes(): void
{
Route::prefix(config('package.route.prefix', 'api'))
->middleware(config('package.route.middleware', []))
->group(fn() => $this->loadRoutesFrom(__DIR__.'/../routes/api.php'));
}
}
// src/Concerns/HasFeature.php
namespace Vendor\Package\Concerns;
trait HasFeature
{
public function initializeHasFeature(): void
{
// Called on model boot
}
public function feature(): MorphMany
{
$model = config('package.model', \Vendor\Package\Models\Feature::class);
return $this->morphMany($model, 'featureable');
}
public function addFeature(BackedEnum $type, mixed $data = null): Model
{
return $this->feature()->create([
'type' => $type->value,
'data' => $data,
]);
}
public function scopeWithFeature($query, BackedEnum $type)
{
return $query->whereHas('feature', fn($q) => $q->where('type', $type->value));
}
}
Usage: use Vendor\Package\Concerns\HasFeature;
// src/helpers.php
if (!function_exists('package')) {
function package(string $source, string $path): UrlBuilder
{
return new UrlBuilder($source, $path);
}
}
class UrlBuilder implements Htmlable, Stringable
{
protected array $options = [];
public function __construct(protected string $source, protected string $path) {}
public function width(int $w): static { $this->options['w'] = $w; return $this; }
public function height(int $h): static { $this->options['h'] = $h; return $this; }
public function format(string $f): static { $this->options['f'] = $f; return $this; }
public function webp(): static { return $this->format('webp'); }
public function url(): string
{
$opts = collect($this->options)->map(fn($v, $k) => "$k=$v")->implode(',');
return "/$opts/{$this->source}/{$this->path}";
}
public function toHtml(): string { return $this->url(); }
public function __toString(): string { return $this->url(); }
}
Blade usage: <img src="{{ package('images', 'photo.jpg')->width(400)->webp() }}">
// config/package.php
return [
'route' => [
'enabled' => true,
'prefix' => null,
'middleware' => [],
'name' => 'package.show',
],
'model' => \Vendor\Package\Models\Item::class,
'rate_limit' => [
'enabled' => true,
'max_attempts' => 10,
],
'cache' => [
'max_age' => 2592000, // 30 days
's_maxage' => 2592000,
'immutable' => true,
],
];
Use .php.stub for publishable migrations:
// database/migrations/create_items_table.php.stub
return new class extends Migration
{
public function up(): void
{
Schema::create(config('package.table', 'items'), function (Blueprint $table) {
$table->id();
$table->morphs('itemable');
$table->string('type');
$table->json('data')->nullable();
$table->timestamps();
$table->index(['itemable_id', 'itemable_type']);
});
}
};
class PackageCommand extends Command
{
protected $signature = 'package:process
{--list : List items without processing}
{--pretend : Show what would be processed}
{--dry-run : Alias for --pretend}';
public function handle(): int
{
if ($this->option('list')) {
return $this->listItems();
}
$pretend = $this->option('pretend') || $this->option('dry-run');
foreach ($this->discoverItems() as $item) {
if ($pretend) {
$this->line("Would process: {$item->name}");
continue;
}
$this->processItem($item);
$this->info("Processed: {$item->name}");
}
return self::SUCCESS;
}
}
// tests/TestCase.php
abstract class TestCase extends \Orchestra\Testbench\TestCase
{
protected function getPackageProviders($app): array
{
return [PackageServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('app.timezone', 'UTC');
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
}
// tests/FeatureTest.php
uses(TestCase::class);
it('processes items correctly', function () {
$item = Item::factory()->create();
$result = $item->process();
expect($result)->toBeTrue();
});
Sharding (different from --parallel): Split tests across CI matrix jobs:
vendor/bin/pest --shard=1/3 # Run in separate CI jobs
vendor/bin/pest --shard=2/3
vendor/bin/pest --shard=3/3
Use sharding for large test suites; --parallel runs tests concurrently within a single process.
Use /setup-laravel-package-workflows to set up:
Use /setup-release-workflow to set up release automation.
Testbench resolution: Don't specify testbench in the workflow—composer resolves the correct version based on the Laravel constraint.
prefer-lowest failures: Bump minimum version constraint if an old dependency lacks a needed feature. Never skip prefer-lowest—it catches accidental dependency on newer features.
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Initial release
[Unreleased]: https://github.com/vendor/package/compare/HEAD
class PathValidator
{
public static function directories(array $allowed): Closure
{
return fn(string $path) => Str::startsWith($path, $allowed);
}
public static function extensions(array $allowed): Closure
{
return fn(string $path) => in_array(pathinfo($path, PATHINFO_EXTENSION), $allowed);
}
}
// In service
public function resolve(string $path): void
{
if (str_contains($path, '..')) {
throw new InvalidPathException('Directory traversal not allowed');
}
$validator = config('package.validator');
if ($validator && !$validator($path)) {
throw new InvalidPathException('Path validation failed');
}
}
Interface-based discovery:
interface Processable {
public static function process(): void;
public static function shouldProcess(CallbackEvent $event): CallbackEvent|bool;
}
Enum-based types:
// config: 'types' => ['user' => UserType::class]
class TypeRegistry {
public static function getAlias(BackedEnum $enum): string { }
public static function getClass(string $alias): string { }
}
Prune/cleanup commands:
interface Pruneable {
public function prune(): ?PruneConfig;
}
class PruneConfig {
public function __construct(
public ?Carbon $before = null,
public ?int $keep = null,
) {}
}
| Mistake | Fix |
|---|---|
use App\Models\User |
config('package.user_model') |
env('KEY') in code |
config('package.key') |
| No CHANGELOG.md | Keep a Changelog format |
| Missing --pretend flag | Add for destructive commands |
How can I help you explore Laravel packages today?