mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-19 11:14:06 +00:00
Laravel 13 upgrade, updates and fixes
This commit is contained in:
106
.cursor/skills/medialibrary-development/SKILL.md
Normal file
106
.cursor/skills/medialibrary-development/SKILL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: medialibrary-development
|
||||
description: Build and work with spatie/laravel-medialibrary features including associating files with Eloquent models, defining media collections and conversions, generating responsive images, and retrieving media URLs and paths.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: Spatie
|
||||
---
|
||||
|
||||
# Media Library Development
|
||||
|
||||
## Overview
|
||||
|
||||
Use spatie/laravel-medialibrary to associate files with Eloquent models. Supports image/video conversions, responsive images, multiple collections, and various storage disks.
|
||||
|
||||
## When to Activate
|
||||
|
||||
- Activate when working with file uploads, media attachments, or image processing in Laravel.
|
||||
- Activate when code references `HasMedia`, `InteractsWithMedia`, the `Media` model, or media collections/conversions.
|
||||
- Activate when the user wants to add, retrieve, convert, or manage files attached to Eloquent models.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope: media uploads, collections, conversions, responsive images, custom properties, file retrieval, path/URL generation.
|
||||
- Out of scope: general file storage without Eloquent association, non-Laravel frameworks.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the task (model setup, adding media, defining conversions, retrieving files, etc.).
|
||||
2. Read `references/medialibrary-guide.md` and focus on the relevant section.
|
||||
3. Apply the patterns from the reference, keeping code minimal and Laravel-native.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Model Setup
|
||||
|
||||
Every model that should have media must implement `HasMedia` and use the `InteractsWithMedia` trait:
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
class BlogPost extends Model implements HasMedia
|
||||
{
|
||||
use InteractsWithMedia;
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Media
|
||||
|
||||
```php
|
||||
$blogPost->addMedia($file)->toMediaCollection('images');
|
||||
$blogPost->addMediaFromUrl($url)->toMediaCollection('images');
|
||||
$blogPost->addMediaFromRequest('file')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### Defining Collections
|
||||
|
||||
```php
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('avatar')->singleFile();
|
||||
$this->addMediaCollection('downloads')->useDisk('s3');
|
||||
}
|
||||
```
|
||||
|
||||
### Defining Conversions
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 300, 300)
|
||||
->nonQueued();
|
||||
}
|
||||
```
|
||||
|
||||
### Retrieving Media
|
||||
|
||||
```php
|
||||
$url = $model->getFirstMediaUrl('images');
|
||||
$thumbUrl = $model->getFirstMediaUrl('images', 'thumb');
|
||||
$allMedia = $model->getMedia('images');
|
||||
```
|
||||
|
||||
## Do and Don't
|
||||
|
||||
Do:
|
||||
- Always implement the `HasMedia` interface alongside the `InteractsWithMedia` trait.
|
||||
- Use `?Media $media = null` as the parameter for `registerMediaConversions()`.
|
||||
- Call `->toMediaCollection()` to finalize adding media.
|
||||
- Use `->nonQueued()` for conversions that should run synchronously.
|
||||
- Use `->singleFile()` on collections that should only hold one file.
|
||||
- Use `Spatie\Image\Enums\Fit` enum values for fit methods.
|
||||
|
||||
Don't:
|
||||
- Don't forget to run `php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"` before migrating.
|
||||
- Don't use `env()` for disk configuration; use `config()` or set it in `config/media-library.php`.
|
||||
- Don't call `addMedia()` without calling `toMediaCollection()` — the media won't be saved.
|
||||
- Don't reference conversion names that aren't registered in `registerMediaConversions()`.
|
||||
|
||||
## References
|
||||
|
||||
- `references/medialibrary-guide.md`
|
||||
@@ -0,0 +1,577 @@
|
||||
# Laravel Media Library Reference
|
||||
|
||||
Complete reference for `spatie/laravel-medialibrary`. Full documentation: https://spatie.be/docs/laravel-medialibrary
|
||||
|
||||
## Model Setup
|
||||
|
||||
Implement `HasMedia` and use `InteractsWithMedia`:
|
||||
|
||||
```php
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
class BlogPost extends Model implements HasMedia
|
||||
{
|
||||
use InteractsWithMedia;
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('images');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 300, 300);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Adding Media
|
||||
|
||||
### From uploaded file
|
||||
|
||||
```php
|
||||
$model->addMedia($request->file('image'))->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### From request (shorthand)
|
||||
|
||||
```php
|
||||
$model->addMediaFromRequest('image')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### From URL
|
||||
|
||||
```php
|
||||
$model->addMediaFromUrl('https://example.com/image.jpg')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### From string content
|
||||
|
||||
```php
|
||||
$model->addMediaFromString('raw content')->usingFileName('file.txt')->toMediaCollection('files');
|
||||
```
|
||||
|
||||
### From base64
|
||||
|
||||
```php
|
||||
$model->addMediaFromBase64($base64Data)->usingFileName('photo.jpg')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### From stream
|
||||
|
||||
```php
|
||||
$model->addMediaFromStream($stream)->usingFileName('file.pdf')->toMediaCollection('files');
|
||||
```
|
||||
|
||||
### From existing disk
|
||||
|
||||
```php
|
||||
$model->addMediaFromDisk('path/to/file.jpg', 's3')->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### Multiple files from request
|
||||
|
||||
```php
|
||||
$model->addMultipleMediaFromRequest(['images'])->each(function ($fileAdder) {
|
||||
$fileAdder->toMediaCollection('images');
|
||||
});
|
||||
|
||||
$model->addAllMediaFromRequest()->each(function ($fileAdder) {
|
||||
$fileAdder->toMediaCollection('images');
|
||||
});
|
||||
```
|
||||
|
||||
### Copy instead of move
|
||||
|
||||
```php
|
||||
$model->copyMedia($pathToFile)->toMediaCollection('images');
|
||||
// or
|
||||
$model->addMedia($pathToFile)->preservingOriginal()->toMediaCollection('images');
|
||||
```
|
||||
|
||||
## FileAdder Options
|
||||
|
||||
All methods are chainable before calling `toMediaCollection()`:
|
||||
|
||||
```php
|
||||
$model->addMedia($file)
|
||||
->usingName('Custom Name') // display name
|
||||
->usingFileName('custom-name.jpg') // filename on disk
|
||||
->setOrder(3) // order within collection
|
||||
->withCustomProperties(['alt' => 'A landscape photo'])
|
||||
->withManipulations(['thumb' => ['filter' => 'greyscale']])
|
||||
->withResponsiveImages() // generate responsive variants
|
||||
->storingConversionsOnDisk('s3') // put conversions on different disk
|
||||
->addCustomHeaders(['CacheControl' => 'max-age=31536000'])
|
||||
->toMediaCollection('images');
|
||||
```
|
||||
|
||||
### Store on cloud disk
|
||||
|
||||
```php
|
||||
$model->addMedia($file)->toMediaCollectionOnCloudDisk('images');
|
||||
```
|
||||
|
||||
## Media Collections
|
||||
|
||||
Define in `registerMediaCollections()`:
|
||||
|
||||
```php
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
// Basic collection
|
||||
$this->addMediaCollection('images');
|
||||
|
||||
// Single file (replacing previous on new upload)
|
||||
$this->addMediaCollection('avatar')
|
||||
->singleFile();
|
||||
|
||||
// Keep only latest N items
|
||||
$this->addMediaCollection('recent_photos')
|
||||
->onlyKeepLatest(5);
|
||||
|
||||
// Specific disk
|
||||
$this->addMediaCollection('downloads')
|
||||
->useDisk('s3');
|
||||
|
||||
// With conversions disk
|
||||
$this->addMediaCollection('photos')
|
||||
->useDisk('s3')
|
||||
->storeConversionsOnDisk('s3-thumbnails');
|
||||
|
||||
// MIME type restriction
|
||||
$this->addMediaCollection('documents')
|
||||
->acceptsMimeTypes(['application/pdf', 'application/zip']);
|
||||
|
||||
// Custom validation
|
||||
$this->addMediaCollection('images')
|
||||
->acceptsFile(function ($file) {
|
||||
return $file->mimeType === 'image/jpeg';
|
||||
});
|
||||
|
||||
// Fallback URL/path when collection is empty
|
||||
$this->addMediaCollection('avatar')
|
||||
->singleFile()
|
||||
->useFallbackUrl('/images/default-avatar.jpg')
|
||||
->useFallbackPath(public_path('/images/default-avatar.jpg'));
|
||||
|
||||
// Enable responsive images for entire collection
|
||||
$this->addMediaCollection('hero_images')
|
||||
->withResponsiveImages();
|
||||
|
||||
// Collection-specific conversions
|
||||
$this->addMediaCollection('photos')
|
||||
->registerMediaConversions(function () {
|
||||
$this->addMediaConversion('card')
|
||||
->fit(Fit::Crop, 400, 400);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Media Conversions
|
||||
|
||||
Define in `registerMediaConversions()`:
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 300, 300)
|
||||
->nonQueued();
|
||||
|
||||
$this->addMediaConversion('preview')
|
||||
->fit(Fit::Crop, 500, 500)
|
||||
->withResponsiveImages()
|
||||
->queued();
|
||||
|
||||
$this->addMediaConversion('banner')
|
||||
->fit(Fit::Max, 1200, 630)
|
||||
->performOnCollections('images', 'headers')
|
||||
->nonQueued()
|
||||
->sharpen(10);
|
||||
|
||||
// Conditional conversion based on media properties
|
||||
if ($media?->mime_type === 'image/png') {
|
||||
$this->addMediaConversion('png-thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
|
||||
// Keep original format instead of converting to jpg
|
||||
$this->addMediaConversion('web')
|
||||
->fit(Fit::Max, 800, 800)
|
||||
->keepOriginalImageFormat();
|
||||
|
||||
// PDF page rendering
|
||||
$this->addMediaConversion('pdf-preview')
|
||||
->pdfPageNumber(1)
|
||||
->fit(Fit::Contain, 400, 400);
|
||||
|
||||
// Video frame extraction
|
||||
$this->addMediaConversion('video-thumb')
|
||||
->extractVideoFrameAtSecond(5)
|
||||
->fit(Fit::Crop, 300, 300);
|
||||
}
|
||||
```
|
||||
|
||||
### Image Manipulation Methods (via spatie/image)
|
||||
|
||||
Resizing and fitting:
|
||||
- `width(int)`, `height(int)` — constrain dimensions
|
||||
- `fit(Fit, int, int)` — fit within bounds using `Fit::Contain`, `Fit::Max`, `Fit::Fill`, `Fit::Stretch`, `Fit::Crop`
|
||||
- `crop(int, int)` — crop to exact dimensions
|
||||
|
||||
Effects:
|
||||
- `sharpen(int)`, `blur(int)`, `pixelate(int)`
|
||||
- `greyscale()`, `sepia()`
|
||||
- `brightness(int)`, `contrast(int)`, `colorize(int, int, int)`
|
||||
|
||||
Orientation:
|
||||
- `orientation(int)`, `flip(string)`, `rotate(int)`
|
||||
|
||||
Format:
|
||||
- `format(string)` — `'jpg'`, `'png'`, `'webp'`, `'avif'`
|
||||
- `quality(int)` — 1-100
|
||||
|
||||
Other:
|
||||
- `border(int, string, string)`, `watermark(string)`
|
||||
- `optimize()`, `nonOptimized()`
|
||||
|
||||
### Conversion Configuration
|
||||
|
||||
- `performOnCollections('col1', 'col2')` — limit to specific collections
|
||||
- `queued()` / `nonQueued()` — run async or sync
|
||||
- `withResponsiveImages()` — also generate responsive variants for this conversion
|
||||
- `keepOriginalImageFormat()` — preserve png/webp/gif instead of converting to jpg
|
||||
- `pdfPageNumber(int)` — which PDF page to render
|
||||
- `extractVideoFrameAtSecond(int)` — video thumbnail timing
|
||||
|
||||
## Retrieving Media
|
||||
|
||||
### Getting media items
|
||||
|
||||
```php
|
||||
$media = $model->getMedia('images'); // all in collection
|
||||
$first = $model->getFirstMedia('images'); // first item
|
||||
$last = $model->getLastMedia('images'); // last item
|
||||
$has = $model->hasMedia('images'); // boolean check
|
||||
```
|
||||
|
||||
### Getting URLs
|
||||
|
||||
```php
|
||||
$url = $model->getFirstMediaUrl('images'); // original URL
|
||||
$thumbUrl = $model->getFirstMediaUrl('images', 'thumb'); // conversion URL
|
||||
$lastUrl = $model->getLastMediaUrl('images', 'thumb');
|
||||
```
|
||||
|
||||
### Getting paths
|
||||
|
||||
```php
|
||||
$path = $model->getFirstMediaPath('images');
|
||||
$thumbPath = $model->getFirstMediaPath('images', 'thumb');
|
||||
```
|
||||
|
||||
### Temporary URLs (S3)
|
||||
|
||||
```php
|
||||
$tempUrl = $model->getFirstTemporaryUrl(
|
||||
now()->addMinutes(30),
|
||||
'images',
|
||||
'thumb'
|
||||
);
|
||||
```
|
||||
|
||||
### Fallback URLs
|
||||
|
||||
```php
|
||||
$url = $model->getFallbackMediaUrl('avatar');
|
||||
```
|
||||
|
||||
### From the Media model
|
||||
|
||||
```php
|
||||
$media = $model->getFirstMedia('images');
|
||||
|
||||
$media->getUrl(); // original URL
|
||||
$media->getUrl('thumb'); // conversion URL
|
||||
$media->getPath(); // disk path
|
||||
$media->getFullUrl(); // full URL with domain
|
||||
$media->getTemporaryUrl(now()->addMinutes(30));
|
||||
$media->hasGeneratedConversion('thumb'); // check if conversion exists
|
||||
```
|
||||
|
||||
### Filtering media
|
||||
|
||||
```php
|
||||
$media = $model->getMedia('images', function (Media $media) {
|
||||
return $media->getCustomProperty('featured') === true;
|
||||
});
|
||||
|
||||
$media = $model->getMedia('images', ['mime_type' => 'image/jpeg']);
|
||||
```
|
||||
|
||||
## Custom Properties
|
||||
|
||||
Store arbitrary metadata on media items:
|
||||
|
||||
```php
|
||||
// When adding
|
||||
$model->addMedia($file)
|
||||
->withCustomProperties([
|
||||
'alt' => 'Descriptive text',
|
||||
'credits' => 'Photographer Name',
|
||||
])
|
||||
->toMediaCollection('images');
|
||||
|
||||
// Get/set on existing media
|
||||
$media->setCustomProperty('alt', 'Updated text');
|
||||
$media->save();
|
||||
|
||||
$alt = $media->getCustomProperty('alt');
|
||||
$has = $media->hasCustomProperty('alt');
|
||||
$media->forgetCustomProperty('alt');
|
||||
$media->save();
|
||||
```
|
||||
|
||||
## Responsive Images
|
||||
|
||||
Generate multiple sizes for optimal loading:
|
||||
|
||||
```php
|
||||
// On the FileAdder
|
||||
$model->addMedia($file)
|
||||
->withResponsiveImages()
|
||||
->toMediaCollection('images');
|
||||
|
||||
// On a conversion
|
||||
$this->addMediaConversion('hero')
|
||||
->fit(Fit::Max, 1200, 800)
|
||||
->withResponsiveImages();
|
||||
|
||||
// On a collection
|
||||
$this->addMediaCollection('photos')
|
||||
->withResponsiveImages();
|
||||
```
|
||||
|
||||
### Using in Blade
|
||||
|
||||
```blade
|
||||
{{-- Renders img tag with srcset --}}
|
||||
{{ $media->toHtml() }}
|
||||
|
||||
{{-- With attributes --}}
|
||||
{{ $media->img()->attributes(['class' => 'w-full', 'alt' => 'Photo']) }}
|
||||
|
||||
{{-- Get srcset string --}}
|
||||
<img src="{{ $media->getUrl() }}" srcset="{{ $media->getSrcset() }}" />
|
||||
|
||||
{{-- Responsive conversion --}}
|
||||
<img src="{{ $media->getUrl('hero') }}" srcset="{{ $media->getSrcset('hero') }}" />
|
||||
```
|
||||
|
||||
### Placeholder SVG
|
||||
|
||||
```php
|
||||
$svg = $media->responsiveImages()->getPlaceholderSvg(); // tiny blurred base64 placeholder
|
||||
```
|
||||
|
||||
## Managing Media
|
||||
|
||||
### Clear a collection
|
||||
|
||||
```php
|
||||
$model->clearMediaCollection('images');
|
||||
```
|
||||
|
||||
### Clear except specific items
|
||||
|
||||
```php
|
||||
$model->clearMediaCollectionExcept('images', $mediaToKeep);
|
||||
```
|
||||
|
||||
### Delete specific media
|
||||
|
||||
```php
|
||||
$model->deleteMedia($mediaId);
|
||||
```
|
||||
|
||||
### Delete all media
|
||||
|
||||
```php
|
||||
$model->deleteAllMedia();
|
||||
```
|
||||
|
||||
### Delete model but keep media files
|
||||
|
||||
```php
|
||||
$model->deletePreservingMedia();
|
||||
```
|
||||
|
||||
### Reorder media
|
||||
|
||||
```php
|
||||
Media::setNewOrder([3, 1, 2]); // media IDs in desired order
|
||||
```
|
||||
|
||||
### Move/copy media between models
|
||||
|
||||
```php
|
||||
$media->move($otherModel, 'images');
|
||||
$media->copy($otherModel, 'images');
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\MediaCollections\Events\MediaHasBeenAddedEvent;
|
||||
use Spatie\MediaLibrary\Conversions\Events\ConversionWillStartEvent;
|
||||
use Spatie\MediaLibrary\Conversions\Events\ConversionHasBeenCompletedEvent;
|
||||
use Spatie\MediaLibrary\MediaCollections\Events\CollectionHasBeenClearedEvent;
|
||||
```
|
||||
|
||||
Listen to these events to hook into the media lifecycle:
|
||||
```php
|
||||
Event::listen(MediaHasBeenAddedEvent::class, function ($event) {
|
||||
$event->media; // the added Media model
|
||||
});
|
||||
|
||||
Event::listen(ConversionHasBeenCompletedEvent::class, function ($event) {
|
||||
$event->media;
|
||||
$event->conversion;
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Key `config/media-library.php` options:
|
||||
|
||||
```php
|
||||
return [
|
||||
'disk_name' => 'public', // default disk
|
||||
'max_file_size' => 1024 * 1024 * 10, // 10MB
|
||||
'queue_connection_name' => '', // queue connection
|
||||
'queue_name' => '', // queue name
|
||||
'queue_conversions_by_default' => true, // queue conversions
|
||||
'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class,
|
||||
'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class,
|
||||
'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class,
|
||||
'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class,
|
||||
'image_driver' => 'gd', // 'gd', 'imagick', or 'vips'
|
||||
'image_optimizers' => [/* optimizer config */],
|
||||
'version_urls' => true, // cache busting
|
||||
'default_loading_attribute_value' => null, // 'lazy' for lazy loading
|
||||
];
|
||||
```
|
||||
|
||||
### Custom Path Generator
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;
|
||||
|
||||
class CustomPathGenerator implements PathGenerator
|
||||
{
|
||||
public function getPath(Media $media): string
|
||||
{
|
||||
return md5($media->id) . '/';
|
||||
}
|
||||
|
||||
public function getPathForConversions(Media $media): string
|
||||
{
|
||||
return $this->getPath($media) . 'conversions/';
|
||||
}
|
||||
|
||||
public function getPathForResponsiveImages(Media $media): string
|
||||
{
|
||||
return $this->getPath($media) . 'responsive/';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom File Namer
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\Support\FileNamer\FileNamer;
|
||||
|
||||
class CustomFileNamer extends FileNamer
|
||||
{
|
||||
public function originalFileName(string $fileName): string
|
||||
{
|
||||
return Str::slug(pathinfo($fileName, PATHINFO_FILENAME));
|
||||
}
|
||||
|
||||
public function conversionFileName(string $fileName, Conversion $conversion): string
|
||||
{
|
||||
return $this->originalFileName($fileName) . '-' . $conversion->getName();
|
||||
}
|
||||
|
||||
public function responsiveFileName(string $fileName): string
|
||||
{
|
||||
return pathinfo($fileName, PATHINFO_FILENAME);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Media Model
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media as BaseMedia;
|
||||
|
||||
class Media extends BaseMedia
|
||||
{
|
||||
// Add custom methods, scopes, or override behavior
|
||||
}
|
||||
```
|
||||
|
||||
Register in config: `'media_model' => App\Models\Media::class`
|
||||
|
||||
## Downloading Media
|
||||
|
||||
### Single file
|
||||
|
||||
```php
|
||||
return $media->toResponse($request); // download
|
||||
return $media->toInlineResponse($request); // display inline
|
||||
return $media->stream(); // stream
|
||||
```
|
||||
|
||||
### ZIP download of collection
|
||||
|
||||
```php
|
||||
use Spatie\MediaLibrary\Support\MediaStream;
|
||||
|
||||
return MediaStream::create('photos.zip')
|
||||
->addMedia($model->getMedia('images'));
|
||||
```
|
||||
|
||||
## Using with API Resources
|
||||
|
||||
```php
|
||||
class PostResource extends JsonResource
|
||||
{
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'image' => $this->getFirstMediaUrl('images'),
|
||||
'thumb' => $this->getFirstMediaUrl('images', 'thumb'),
|
||||
'media' => $this->getMedia('images')->map(function ($media) {
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'url' => $media->getUrl(),
|
||||
'thumb' => $media->getUrl('thumb'),
|
||||
'name' => $media->name,
|
||||
'size' => $media->size,
|
||||
'type' => $media->mime_type,
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
157
.cursor/skills/pest-testing/SKILL.md
Normal file
157
.cursor/skills/pest-testing/SKILL.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Pest Testing 4
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
<!-- Basic Pest Test Example -->
|
||||
```php
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
|
||||
- Run all tests: `php artisan test --compact`.
|
||||
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
<!-- Pest Response Assertion -->
|
||||
```php
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
```
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
<!-- Pest Dataset Example -->
|
||||
```php
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
```
|
||||
|
||||
## Pest 4 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Test Sharding | Parallel CI runs |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
<!-- Pest Browser Test Example -->
|
||||
```php
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
```
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
<!-- Pest Smoke Testing Example -->
|
||||
```php
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
```
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Test Sharding
|
||||
|
||||
Split tests across parallel processes for faster CI runs.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
Pest 4 includes architecture testing (from Pest 3):
|
||||
|
||||
<!-- Architecture Test Example -->
|
||||
```php
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
91
.cursor/skills/tailwindcss-development/SKILL.md
Normal file
91
.cursor/skills/tailwindcss-development/SKILL.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v3 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v3 Specifics
|
||||
|
||||
- Always use Tailwind CSS v3 and verify you're using only classes it supports.
|
||||
- Configuration is done in the `tailwind.config.js` file.
|
||||
- Import using `@tailwind` directives:
|
||||
|
||||
<!-- v3 Import Syntax -->
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
```
|
||||
|
||||
## Spacing
|
||||
|
||||
When listing items, use gap utilities for spacing; don't use margins.
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check browser for visual rendering
|
||||
2. Test responsive breakpoints
|
||||
3. Verify dark mode if project uses it
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
- Not checking existing project conventions before adding new utilities
|
||||
- Overusing inline styles when Tailwind classes would suffice
|
||||
Reference in New Issue
Block a user