Save workspace changes
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* High-level search API powered by Meilisearch via Laravel Scout.
|
||||
*
|
||||
* No Meili calls in controllers — always go through this service.
|
||||
*/
|
||||
final class ArtworkSearchService
|
||||
{
|
||||
private const BASE_FILTER = 'is_public = true AND is_approved = true';
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
|
||||
private const SEARCH_CANDIDATE_POOL_MULTIPLIER = 4;
|
||||
private const SEARCH_CANDIDATE_POOL_MAX = 240;
|
||||
|
||||
public function __construct(
|
||||
private readonly AdaptiveTimeWindow $timeWindow,
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Full-text search with optional filters.
|
||||
*
|
||||
* Supported $filters keys:
|
||||
* tags array<string> — tag slugs (AND match)
|
||||
* category string
|
||||
* orientation string — landscape | portrait | square
|
||||
* resolution string — e.g. "1920x1080"
|
||||
* author_id int
|
||||
* sort string — created_at|downloads|likes|views (suffix :asc or :desc)
|
||||
*/
|
||||
public function search(string $q, array $filters = [], int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$filterParts = [self::BASE_FILTER];
|
||||
$sort = [];
|
||||
|
||||
if (! empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$filterParts[] = 'tags = "' . addslashes((string) $tag) . '"';
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($filters['category'])) {
|
||||
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
|
||||
}
|
||||
|
||||
if (! empty($filters['orientation'])) {
|
||||
$filterParts[] = 'orientation = "' . addslashes((string) $filters['orientation']) . '"';
|
||||
}
|
||||
|
||||
if (! empty($filters['resolution'])) {
|
||||
$filterParts[] = 'resolution = "' . addslashes((string) $filters['resolution']) . '"';
|
||||
}
|
||||
|
||||
if (! empty($filters['author_id'])) {
|
||||
$filterParts[] = 'author_id = ' . (int) $filters['author_id'];
|
||||
}
|
||||
|
||||
if (! empty($filters['sort'])) {
|
||||
[$field, $dir] = $this->parseSort((string) $filters['sort']);
|
||||
if ($field) {
|
||||
$sort[] = $field . ':' . $dir;
|
||||
}
|
||||
}
|
||||
|
||||
$options = ['filter' => implode(' AND ', $filterParts)];
|
||||
if ($sort !== []) {
|
||||
$options['sort'] = $sort;
|
||||
}
|
||||
|
||||
$options = $this->viewerAwareOptions($options);
|
||||
|
||||
return Artwork::search($q ?: '')
|
||||
->options($options)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator
|
||||
{
|
||||
$page = max(1, $page ?? (int) request()->get('page', 1));
|
||||
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
|
||||
$results = Artwork::search('')
|
||||
->options($this->viewerAwareOptions($options))
|
||||
->paginate($candidateCount, 'page', 1);
|
||||
|
||||
$ordered = $this->rerankSearchCollectionByThumbnailHealth($results->getCollection(), $excludeMissing);
|
||||
$offset = max(0, ($page - 1) * $perPage);
|
||||
$slice = $ordered->slice($offset, $perPage)->values();
|
||||
|
||||
return new PaginationLengthAwarePaginator(
|
||||
$slice->all(),
|
||||
(int) $results->total(),
|
||||
$perPage,
|
||||
$page,
|
||||
[
|
||||
'path' => request()->url(),
|
||||
'query' => request()->query(),
|
||||
'pageName' => 'page',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load artworks for a tag page, sorted by views + likes descending.
|
||||
*/
|
||||
public function byTag(string $slug, int $perPage = 24, string $sort = 'popular'): LengthAwarePaginator
|
||||
{
|
||||
$tag = Tag::where('slug', $slug)->first();
|
||||
if (! $tag) {
|
||||
return $this->emptyPaginator($perPage);
|
||||
}
|
||||
|
||||
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
|
||||
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
|
||||
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->with(['user.profile', 'categories.contentType']);
|
||||
|
||||
match ($sort) {
|
||||
'likes' => $query
|
||||
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
|
||||
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
||||
->orderByDesc('artworks.published_at'),
|
||||
'latest' => $query
|
||||
->orderByDesc('artworks.published_at')
|
||||
->orderByDesc('artworks.id'),
|
||||
'downloads' => $query
|
||||
->orderByRaw('COALESCE(artwork_stats.downloads, 0) DESC')
|
||||
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
||||
->orderByDesc('artworks.published_at'),
|
||||
default => $query
|
||||
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
||||
->orderByRaw('COALESCE(artwork_stats.favorites, 0) DESC')
|
||||
->orderByDesc('artworks.published_at'),
|
||||
};
|
||||
|
||||
return $query
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load artworks for a category, sorted by created_at desc.
|
||||
*/
|
||||
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$cacheKey = "search.cat.catalog-visible.v2.{$cat}.{$this->viewerCacheSegment()}.page." . $page;
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage, $page) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
|
||||
'sort' => ['created_at:desc'],
|
||||
], $perPage, false, $page);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Category / Content-Type page sorts ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Meilisearch sort fields per alias.
|
||||
* Used by categoryPageSort() and contentTypePageSort().
|
||||
*/
|
||||
private const CATEGORY_SORT_FIELDS = [
|
||||
'trending' => ['trending_score_24h:desc', 'published_at_ts:desc'],
|
||||
'fresh' => ['published_at_ts:desc'],
|
||||
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
|
||||
'favorited' => ['favorites_count:desc', 'trending_score_24h:desc'],
|
||||
'downloaded' => ['downloads_count:desc', 'trending_score_24h:desc'],
|
||||
'oldest' => ['published_at_ts:asc'],
|
||||
];
|
||||
|
||||
/** Cache TTL (seconds) per sort alias for category pages. */
|
||||
private const CATEGORY_SORT_TTL = [
|
||||
'trending' => 300, // 5 min
|
||||
'fresh' => 120, // 2 min
|
||||
'top-rated' => 600, // 10 min
|
||||
'favorited' => 300,
|
||||
'downloaded' => 300,
|
||||
'oldest' => 600,
|
||||
];
|
||||
|
||||
/**
|
||||
* Artworks for a single category page, sorted via Meilisearch.
|
||||
* Default sort: trending (trending_score_24h:desc).
|
||||
*
|
||||
* Cache key pattern: category.{slug}.{sort}.{page}
|
||||
* TTL varies by sort (see spec: 5/2/10 min).
|
||||
*/
|
||||
public function categoryPageSort(string $categorySlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
||||
$page = (int) request()->get('page', 1);
|
||||
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
|
||||
$cacheKey = "category.catalog-visible.v2.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
|
||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||
], $perPage, false, (int) request()->get('page', 1));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Artworks for a content-type root page, sorted via Meilisearch.
|
||||
* Default sort: trending.
|
||||
*
|
||||
* Cache key pattern: content_type.{slug}.{sort}.{page}
|
||||
*/
|
||||
public function contentTypePageSort(string $contentTypeSlug, string $sort = 'trending', int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
|
||||
$page = (int) request()->get('page', 1);
|
||||
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
|
||||
$cacheKey = "content_type.catalog-visible.v2.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
|
||||
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
|
||||
], $perPage, false, (int) request()->get('page', 1));
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Related artworks: same tags, different artwork, ranked by views + likes.
|
||||
* Limit 12.
|
||||
*/
|
||||
public function related(Artwork $artwork, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
$tags = $artwork->tags()->pluck('tags.slug')->values()->all();
|
||||
|
||||
if ($tags === []) {
|
||||
return $this->popular($limit);
|
||||
}
|
||||
|
||||
$cacheKey = "search.related.{$artwork->id}.{$this->viewerCacheSegment()}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) {
|
||||
$tagFilters = implode(' OR ', array_map(
|
||||
fn ($t) => 'tags = "' . addslashes($t) . '"',
|
||||
$tags
|
||||
));
|
||||
|
||||
return Artwork::search('')
|
||||
->options($this->viewerAwareOptions([
|
||||
'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')',
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
]))
|
||||
->paginate($limit);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Most popular artworks by views.
|
||||
*/
|
||||
public function popular(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options($this->viewerAwareOptions([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
]))
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent artworks by publish timestamp.
|
||||
*/
|
||||
public function recent(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options($this->viewerAwareOptions([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['published_at_ts:desc'],
|
||||
]))
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Discover section helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trending: sorted by Ranking Engine V2 `ranking_score` (recalculated every 30 min).
|
||||
*
|
||||
* Spec §6: Uses ranking_score, limits to last 30 days,
|
||||
* highlights high-velocity artworks via engagement_velocity tiebreaker.
|
||||
*/
|
||||
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||
$cutoff = now()->subDays($windowDays)->toDateString();
|
||||
// Include window in cache key so adaptive expansions surface immediately
|
||||
$cacheKey = "discover.trending.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($perPage, $cutoff) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
||||
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
|
||||
], $perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rising: sorted by heat_score (recalculated every 15 min).
|
||||
*
|
||||
* Surfaces artworks with rapid recent engagement growth.
|
||||
* Restricts to last 30 days, sorted by heat_score DESC.
|
||||
*/
|
||||
public function discoverRising(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
|
||||
$cutoff = now()->subDays($windowDays)->toDateString();
|
||||
$cacheKey = "discover.rising.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
|
||||
|
||||
return Cache::remember($cacheKey, 120, function () use ($perPage, $cutoff) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
|
||||
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
|
||||
], $perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh: newest uploads first.
|
||||
*/
|
||||
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['published_at_ts:desc'],
|
||||
], $perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Top rated: highest number of favourites/likes.
|
||||
*/
|
||||
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['likes:desc', 'views:desc'],
|
||||
], $perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Most downloaded: highest download count.
|
||||
*/
|
||||
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['downloads:desc', 'views:desc'],
|
||||
], $perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Artworks matching any of the given tag slugs, sorted by trending score.
|
||||
* Used for personalized "Because you like {tags}" homepage section.
|
||||
*
|
||||
* @param string[] $tagSlugs
|
||||
*/
|
||||
public function discoverByTags(array $tagSlugs, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
if (empty($tagSlugs)) {
|
||||
return $this->popular($limit);
|
||||
}
|
||||
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||
array_slice($tagSlugs, 0, 5)
|
||||
));
|
||||
|
||||
$cacheKey = 'discover.by-tags.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $tagSlugs));
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
|
||||
'sort' => ['trending_score_7d:desc', 'likes:desc'],
|
||||
], $limit, true, 1);
|
||||
});
|
||||
}
|
||||
|
||||
private function viewerAwareOptions(array $options): array
|
||||
{
|
||||
$options['filter'] = $this->maturity->appendSearchFilter((string) ($options['filter'] ?? self::BASE_FILTER), request()->user());
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function viewerCacheSegment(): string
|
||||
{
|
||||
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh artworks in given categories, sorted by publish timestamp desc.
|
||||
* Used for personalized "Fresh in your favourite categories" section.
|
||||
*
|
||||
* @param string[] $categorySlugs
|
||||
*/
|
||||
public function discoverByCategories(array $categorySlugs, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
if (empty($categorySlugs)) {
|
||||
return $this->recent($limit);
|
||||
}
|
||||
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
array_slice($categorySlugs, 0, 3)
|
||||
));
|
||||
|
||||
$cacheKey = 'discover.by-cats.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $categorySlugs));
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
|
||||
return $this->searchWithThumbnailPreference([
|
||||
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
|
||||
'sort' => ['published_at_ts:desc'],
|
||||
], $limit, true, 1);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function parseSort(string $sort): array
|
||||
{
|
||||
$allowed = ['created_at', 'downloads', 'likes', 'views'];
|
||||
$parts = explode(':', $sort, 2);
|
||||
$field = $parts[0] ?? '';
|
||||
$dir = strtolower($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
|
||||
}
|
||||
|
||||
private function rerankSearchCollectionByThumbnailHealth(Collection $items, bool $excludeMissing): Collection
|
||||
{
|
||||
if ($items->isEmpty()) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
$ids = $items
|
||||
->pluck('id')
|
||||
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
|
||||
->map(fn ($id) => (int) $id)
|
||||
->values();
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return $items->values();
|
||||
}
|
||||
|
||||
$missingIds = Artwork::query()
|
||||
->whereIn('id', $ids)
|
||||
->where('has_missing_thumbnails', true)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->flip();
|
||||
|
||||
if ($missingIds->isEmpty()) {
|
||||
return $items->values();
|
||||
}
|
||||
|
||||
$healthy = $items->reject(fn ($item) => $missingIds->has((int) ($item->id ?? 0)));
|
||||
|
||||
if ($excludeMissing) {
|
||||
return $healthy->values();
|
||||
}
|
||||
|
||||
return $healthy
|
||||
->concat($items->filter(fn ($item) => $missingIds->has((int) ($item->id ?? 0))))
|
||||
->values();
|
||||
}
|
||||
|
||||
private function determineSearchCandidatePoolSize(int $perPage, int $page): int
|
||||
{
|
||||
return min(
|
||||
self::SEARCH_CANDIDATE_POOL_MAX,
|
||||
max($perPage, $perPage * max(self::SEARCH_CANDIDATE_POOL_MULTIPLIER, $page + 2))
|
||||
);
|
||||
}
|
||||
|
||||
private function emptyPaginator(int $perPage): LengthAwarePaginator
|
||||
{
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user