소스 정보
- 저장소
- cosmix/loom
- 최근 소스 활동
- 2026년 9월 25일 06:00
- 감지된 SKILL.md 언어
- 영어
- 스타
- 61
- 포크
- 4
설치 방법
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
소스 파일 검토
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
SKILL.md 표시 중
SKILL.md
소스 지침 · 읽기 전용 미리보기- name
- loom-php
- description
- PHP language expertise for idiomatic, production-quality code.
- triggers
- ["php","composer","packagist","psr-4","phpunit","pest","laravel","eloquent","artisan","symfony","doctrine","phpstan","psalm","php-cs-fixer","pint","rector","octane","php-fpm"]
# PHP Language Expertise
## Overview
Idiomatic, production-grade PHP 8.x: Composer, strict types, enums and readonly, the Laravel and Symfony essentials, and PHPUnit/Pest. Assumes competence; the value is in what is easy to get wrong (coercive typing, loose comparison, the by-reference `foreach` trap, state leaking in long-running workers, N+1 queries, unsafe `unserialize`) and in writing tests that loom's `phpunit` and `pest` adapters can select one at a time.
## Tooling
**Composer** manages dependencies and generates the autoloader.
| Task | Command |
| --- | --- |
| Add a dependency | `composer require guzzlehttp/guzzle` (updates `composer.json` and `composer.lock`) |
| Add a dev dependency | `composer require --dev phpunit/phpunit` |
| Install from the lock | `composer install` (CI: `--no-interaction --prefer-dist`) |
| Production install | `composer install --no-dev --classmap-authoritative` |
| Update one package | `composer update vendor/package --with-dependencies` |
| Regenerate the autoloader | `composer dump-autoload` (after editing `autoload`) |
| Check the manifest | `composer validate --strict` |
| Known vulnerabilities | `composer audit` |
- Commit `composer.lock` for applications. Add and remove packages with `composer require`/`composer remove`; the `autoload`, `config` and `scripts` sections are edited by hand.
- `config.platform.php` pins the PHP version used for resolution, so a newer local PHP cannot lock packages the server cannot run.
- PSR-4: one class per file, namespace prefix mapped to a directory; a class whose path does not match its namespace fails to autoload only when first used.
```json
{
"require": { "php": "^8.3" },
"autoload": { "psr-4": { "App\\": "src/" } },
"autoload-dev": { "psr-4": { "Tests\\": "tests/" } },
"config": { "platform": { "php": "8.3.0" }, "sort-packages": true },
"scripts": { "test": "phpunit", "analyse": "phpstan analyse" }
}
```
**Format:** PHP-CS-Fixer (`vendor/bin/php-cs-fixer fix`), Laravel Pint (`vendor/bin/pint`, PHP-CS-Fixer with Laravel presets), or PHP_CodeSniffer (`phpcs`/`phpcbf`) with PSR-12 / PER Coding Style.
**Static analysis:** PHPStan (`vendor/bin/phpstan analyse`; levels 0 to 10 in PHPStan 2, `max` is the highest) or Psalm; Larastan adds Laravel's magic. Adopting on legacy code, generate a baseline (`--generate-baseline`) and shrink it; never add new errors to it. Generics exist only in docblocks for these tools: `@param list<User>`, `@return array<string, int>`, `@template T`.
**Upgrades:** Rector (`vendor/bin/rector process --dry-run`) automates version migrations and PHPUnit attribute conversion.
**The gate:**
```bash
composer validate --strict
vendor/bin/php-cs-fixer fix --dry-run --diff # or: vendor/bin/pint --test
vendor/bin/phpstan analyse
vendor/bin/phpunit # or: vendor/bin/pest
composer audit
```
## Types and Strictness
Start every file with `declare(strict_types=1);`. Without it scalar parameters coerce (`"42"` becomes `42`, `true` becomes `1`). The mode applies to calls made *from* the declaring file, so a strict library called from a non-strict file still receives coerced values.
| Version | Type features |
| --- | --- |
| 8.0 | Union types `int\|string`, `mixed`, `static` return, constructor promotion, named arguments, `match`, nullsafe `?->`, `throw` as an expression, attributes |
| 8.1 | Enums, readonly properties, `never`, intersection types `A&B`, `new` in initializers, callable syntax `strlen(...)`, fibers |
| 8.2 | Readonly classes, DNF types `(A&B)\|null`, standalone `true`/`false`/`null`, dynamic properties deprecated |
| 8.3 | Typed class constants, `#[\Override]`, `json_validate()` |
| 8.4 | Property hooks, asymmetric visibility `public private(set)`, `new Foo()->method()` without wrapping parentheses; implicit nullable parameters (`Foo $x = null`) deprecated, write `?Foo $x = null` |
```php
<?php
declare(strict_types=1);
namespace App\Spool;
final class Writer
{
public function __construct(
private readonly string $root,
private readonly Clock $clock = new SystemClock(), // new in initializer (8.1)
) {}
/** @param list<string> $lines */
public function write(string $jobId, array $lines): int
{
if (is_link($this->root)) {
throw new SymlinkException("spool root is a symlink: {$this->root}");
}
return $this->append($this->root . '/' . $jobId, $lines, $this->clock->now());
}
}
```
## Enums and Readonly
- Pure enums (`enum Suit { case Hearts; ... }`) and backed enums (`enum Status: string`). `Status::from($v)` throws `ValueError` on an unknown value; `Status::tryFrom($v)` returns `null`. `Status::cases()` lists them. Enums take methods, constants and interfaces; they hold no state.
- `match` compares with `===` and throws `UnhandledMatchError` when no arm matches. A `match` over an enum without `default` fails loudly when a case is added; keep it that way.
- Readonly properties are written once, from inside the class, and must be typed. They are shallow: an object held in a readonly property stays mutable. Readonly classes (8.2) make every promoted and declared property readonly and forbid dynamic properties.
- Modify a readonly value object by constructing a new one (`withCurrency()` returning `new self(...)`); 8.3 also allows reinitializing readonly properties inside `__clone`.
- Property hooks (8.4) replace most getter/setter pairs; asymmetric visibility (`public private(set)`) gives public reads with private writes.
```php
enum Status: string
{
case Pending = 'pending';
case Active = 'active';
case Suspended = 'suspended';
public function canLogIn(): bool
{
return match ($this) {
self::Active => true,
self::Pending, self::Suspended => false,
};
}
}
$status = Status::tryFrom($input) ?? throw new \InvalidArgumentException("unknown status: {$input}");
final readonly class Money
{
public function __construct(public int $cents, public string $currency) {}
public function add(self $other): self
{
if ($other->currency !== $this->currency) {
throw new \LogicException('currency mismatch');
}
return new self($this->cents + $other->cents, $this->currency);
}
}
final class Account
{
public string $email {
set => strtolower(trim($value)); // 8.4 set hook
}
public function __construct(public private(set) string $id, string $email)
{
$this->email = $email;
}
}
```
## Errors and Exceptions
- `Throwable` splits into `Error` (engine: `TypeError`, `ValueError`, `ArgumentCountError`, `UnhandledMatchError`, `DivisionByZeroError`) and `Exception`. Catch `Exception` subclasses for recoverable conditions; catch `Throwable` only in the top-level handler.
- Give a library a marker interface (`interface SpoolException extends \Throwable {}`) and concrete classes extending SPL exceptions (`final class SymlinkException extends \RuntimeException implements SpoolException`). Callers catch the interface for everything or a class for one case.
- Chain the cause: `throw new SpoolWriteException('write failed', previous: $e);`.
- `json_decode($s, true, 512, JSON_THROW_ON_ERROR)` and `json_encode($v, JSON_THROW_ON_ERROR)`: without the flag, bad input returns `null`/`false` and the error hides in `json_last_error()`.
- `preg_*` functions return `false` (or `null`) on a regex error such as backtrack-limit exhaustion; check the return and `preg_last_error_msg()`.
- A `return` inside `finally` replaces both the `try` block's return value and any in-flight exception.
- Never silence errors with `@`; the failure disappears from logs and the code carries on with a bad value.
## Concurrency and the Request Model
- PHP-FPM is share-nothing: every request starts with fresh statics, singletons and globals, so per-request leaks vanish at the end of the request.
- Long-running processes (Laravel Octane, RoadRunner, FrankenPHP worker mode, Swoole, queue workers, Messenger consumers) keep the process across requests or jobs. Static properties, container singletons holding request data, and in-memory caches leak between requests, and memory grows. Reset request state explicitly, and restart workers periodically (`php artisan queue:work --max-jobs=1000 --max-time=3600`, `messenger:consume --limit=1000 --memory-limit=256M`).
- Fibers (8.1) are the primitive under AMPHP v3, ReactPHP and Revolt; application code rarely touches them directly.
- Parallel or deferred work goes through a queue (Laravel queues, Symfony Messenger) or separate processes (`symfony/process`).
## Laravel Essentials
- **N+1:** eager load with `with('customer')`; call `Model::preventLazyLoading(! app()->isProduction())` (or `Model::shouldBeStrict()`, which also rejects silently discarded and missing attributes) in `AppServiceProvider::boot`.
- **Mass assignment:** `$fillable` is the allowlist. Pass `$request->validated()` from a FormRequest; never `$guarded = []` together with `$request->all()`.
- **Configuration:** call `env()` only inside `config/*.php`. After `php artisan config:cache`, `env()` elsewhere returns `null`; read `config('services.stripe.key')`.
- **Transactions and queues:** `DB::transaction(fn () => ...)`; dispatch jobs from inside it with `->afterCommit()` so a worker never sees uncommitted rows. `SerializesModels` re-fetches models by key when the job runs; handlers must be idempotent (`$tries`, `backoff()`, `ShouldBeUnique`).
- **Large tables:** `chunkById` / `lazyById`; `chunk` while updating the filtered column skips rows.
- **Laravel 11+** configures middleware, exceptions and routing in `bootstrap/app.php`.
- **Tests:** `RefreshDatabase`, `Http::fake()`, `Queue::fake()`, `Mail::fake()`, `$this->actingAs($user)`.
```php
public function store(StoreOrderRequest $request): JsonResponse
{
$order = DB::transaction(function () use ($request): Order {
$order = Order::create($request->safe()->except('lines'));
$order->lines()->createMany($request->validated('lines'));
SendOrderConfirmation::dispatch($order)->afterCommit();
return $order;
});
return OrderResource::make($order->load('lines'))->response()->setStatusCode(201);
}
```
## Symfony Essentials
- Services are autowired and autoconfigured by default (`config/services.yaml`); inject through constructors. Tag with attributes: `#[AsEventListener]`, `#[AsMessageHandler]`, `#[AsCommand]`.
- Routes via `#[Route]`; `#[MapRequestPayload]` (6.3+) deserializes and validates a request DTO.
- Doctrine: `persist()` schedules, `flush()` writes every pending change in one transaction. Flush once per unit of work; for imports, flush and `clear()` in batches or memory grows with the identity map. Lazy associations cause N+1; fetch-join in the query (`->addSelect('l')`).
- Authorization through voters (`#[IsGranted('EDIT', subject: 'order')]`); forms carry CSRF tokens by default.
- Diagnose with `bin/console debug:container`, `debug:router`, `lint:container`.
```php
#[Route('/api/orders', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateOrder $command, MessageBusInterface $bus): JsonResponse
{
$bus->dispatch($command);
return new JsonResponse(null, Response::HTTP_ACCEPTED);
}
```
## Testing
**PHPUnit (10+).**
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Spool;
use App\Spool\SymlinkException;
use App\Spool\Writer;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class WriterTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = sys_get_temp_dir() . '/spool-' . bin2hex(random_bytes(4));
mkdir($this->root);
}
public function testRejectsSymlinkedSpoolDirectory(): void
{
mkdir($this->root . '/target');
symlink($this->root . '/target', $this->root . '/spool');
$this->expectException(SymlinkException::class);
(new Writer($this->root . '/spool'))->write('job-1', ['payload']);
}
#[DataProvider('invalidJobIds')]
public function testRejectsInvalidJobId(string $jobId): void
{
$this->expectException(\InvalidArgumentException::class);
GitHub에서 보기이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기