ulbrich-media/taily
Taily is a Laravel/PHP package by Ulbrich Media that provides a lightweight, developer-friendly utility for Laravel applications. It emphasizes simple setup, clean integration, and minimal overhead, making it easy to add to existing projects and start using quickly.
taily is a Laravel application/package for animal-shelter operations (animals, adoptions, people, users). v0.12.0 is a substantial feature release: the three main list endpoints (animals, adoptions, people) now support server-side pagination, filtering, sorting and search, plus reusable list-filter presets. Start here:
Install and boot
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed
npm install && npm run dev # frontend assets
If the repo ships a DDEV config, use ddev start && ddev composer install instead — v0.12.0 specifically patches DDEV behaviour for worktrees, so the containerised path is the best-supported one.
Look at these files first
routes/api.php — the animal/adoption/person list endpoints changed shape in this release.animals, adoptions, people — this is where filters, sorting, search and pagination are wired.phpunit.xml — the stateful domain and frontend URL are now pinned here; read it before writing a single test.First use case: paginated, searchable animal list
curl "https://taily.test/api/animals?search=beagle&sort=-name&per_page=25&page=2"
Expect a Laravel paginator envelope (data, links, meta) rather than a bare array. Wire your frontend table to that envelope; the old unpaginated response shape is gone for these endpoints.
Standard list-query contract. All three list resources now share the same vocabulary:
| Param | Purpose | Example |
|---|---|---|
search |
Free-text term | ?search=beagle |
sort |
Field, - prefix for descending |
?sort=-created_at |
filter[...] |
Column-scoped filters | ?filter[status]=available |
per_page |
Page size | ?per_page=50 |
page |
Page number | ?page=3 |
Build one reusable query object or trait and apply it across all three resources so the behaviour stays identical:
final class ListQuery
{
public static function apply(Builder $query, Request $request, array $sortable, array $filterable): Builder
{
return $query
->when($request->string('search')->trim()->value(), fn ($q, $term) =>
$q->where(fn ($q) => $q->where('name', 'like', "%{$term}%")
->orWhere('notes', 'like', "%{$term}%")))
->when($request->input('filter'), fn ($q, $filters) =>
collect($filters)->only($filterable)->each(fn ($v, $k) => $q->where($k, $v)))
->when($request->input('sort'), function ($q, $sort) use ($sortable) {
$dir = str_starts_with($sort, '-') ? 'desc' : 'asc';
$col = ltrim($sort, '-');
return in_array($col, $sortable, true) ? $q->orderBy($col, $dir) : $q;
}, fn ($q) => $q->latest());
}
}
Always whitelist sort and filter keys against an explicit array — never interpolate user input into orderBy.
Preset filters. v0.12.0 adds personalised and archive presets. Treat presets as named bundles of the same query params, resolved server-side so the frontend can ship a single ?preset=archive link:
$presets = [
'archive' => ['filter' => ['archived' => true], 'sort' => '-archived_at'],
'personalised' => ['filter' => ['owner_id' => auth()->id()]],
];
$params = array_merge($presets[$request->input('preset')] ?? [], $request->only(['search', 'sort', 'page']));
Keep presets additive — user-supplied params should still be able to narrow a preset.
User ↔ person linking. Users now optionally relate to a person record. Make the relation nullable and guard reads:
public function person(): BelongsTo { return $this->belongsTo(Person::class); }
// usage
$user->person?->full_name ?? $user->name;
Backfill existing users where possible, but design every display path to tolerate null — the release explicitly calls the link optional.
Frontend integration. Keep list state in the URL query string so preset links, browser back/forward and refresh all behave. Debounce search (~300 ms) and reset page to 1 whenever any filter changes, otherwise combined filters return empty pages.
Testing. Because v0.12.0 pins the stateful domain and frontend URL in phpunit.xml, write feature tests against relative URLs ($this->getJson('/api/animals?search=x')) rather than absolute ones. Use assertJsonStructure(['data', 'links', 'meta']) to lock the paginator contract.
phpunit.xml now hard-codes the stateful domain and frontend URL. If you run tests behind a different host, or override APP_URL in .env.testing, you can get redirect/assertion mismatches. Change it in phpunit.xml, not in .env, and keep it in sync with CI.ddev composer runs take longer. That's expected, not a regression — use the main checkout for heavy installs.page is the classic bug — page 4 of a filtered set is usually empty. Reset page client-side and consider ignoring page server-side when filters are present but the page exceeds lastPage.LIKE '%term%' cannot use a B-tree index. On larger animal/person tables add a pg_trgm/fulltext index or a dedicated search scope behind the same search param so the API contract stays stable.orderBy('id') after the requested sort); without it pagination can duplicate or drop rows across pages when sort values tie.nullOnDelete (or restrict), or deleting a person will take users with it.php artisan config:clear) after touching phpunit.xml/env, re-publish frontend assets (npm run build), and smoke-test all three list endpoints with a sort + filter + search combination before rolling out.How can I help you explore Laravel packages today?