phpanonymous/lynx
Lynx is a Laravel package for rapid backend scaffolding, offering tools for CRUD resources, forms, and admin-style data management to reduce boilerplate and speed up common application development tasks.
Verify the package's real surface before committing to it — it's a 17-star, zero-dependent package with a thin README, so the source is the documentation.
Install and discover the entry point.
composer require phpanonymous/lynx
php artisan list | grep -i lynx
php artisan vendor:publish --provider="PhpAnonymous\\Lynx\\LynxServiceProvider"
Laravel's package discovery should register the service provider. If php artisan list shows no lynx:* command, the package is likely route- or trait-based instead — open vendor/phpanonymous/lynx/src/ and read the service provider first.
Read these four files before writing code: the service provider (what it registers), the config file (naming, prefixes, model paths, and the new storage/disk settings), the generator command class (the exact flags it accepts), and the storage facade class added in 1.0.20. Skipping this is the main reason people bounce off small packages like this.
First use case: scaffold CRUD for an existing model. Point the generator at one model with a simple table and inspect what lands on disk:
php artisan lynx:generate User # confirm exact signature from `php artisan list`
git status # see exactly which files were created
php artisan route:list --path=api
Then hit the endpoint:
curl -s http://localhost:8000/api/users | jq
Validate against a throwaway database first. Run it on a fresh SQLite or a scratch schema — never on a production-connected .env — because generated routes and controllers may be immediately live.
Wire up storage before testing the new upload path. 1.0.20 adds file uploads backed by a storage facade, which means generated controllers/files may now reference a disk that doesn't exist in your app:
php artisan storage:link
# confirm config/lynx.php (or config/filesystems.php) has the disk the package expects
grep -rn "Storage\|disk" vendor/phpanonymous/lynx/src/ | head -40
Then exercise an upload end-to-end with a real multipart request rather than trusting the generated code:
curl -s -X POST http://localhost:8000/api/posts \
-F "title=Hello" \
-F "image=@./fixtures/photo.jpg" | jq
Scaffold-once, own-forever. The dominant pattern for auto-generators is to run them at project kickoff, commit the output, and treat it as hand-written code afterwards. Re-running the generator on an already-customized controller is where data loss happens.
# One-time bootstrap for a new resource
php artisan lynx:generate Post
git add app/Http/Controllers app/Models routes/
git commit -m "chore: scaffold Post API via lynx"
Generate into a versioned, middleware-wrapped route group. The package will likely emit routes at whatever prefix its config specifies. Normalize that immediately so auth and throttling apply uniformly:
// routes/api.php
Route::prefix('v1')
->middleware(['auth:sanctum', 'throttle:api'])
->group(base_path('routes/lynx.php')); // or wherever it writes routes
If the package registers routes itself, override the prefix in config/lynx.php rather than editing generated files.
Table-driven generation for admin CRUD. For back-office APIs with many simple lookup tables, loop the generator over a list and review the diff in bulk:
for m in Category Tag Status Country Currency; do
php artisan lynx:generate "$m"
done
git diff --stat
This is the package's strongest use case: high-volume, low-complexity resources where the boilerplate-to-value ratio is worst if written by hand.
Route file uploads through the package's storage facade, then wrap it. Since 1.0.20, generated upload handling goes through a facade provided by the package. Treat it as an adapter: call it from your own action/FormRequest rather than scattering facade calls through controllers, so a future version bump only touches one seam.
// app/Actions/StorePostImage.php
final class StorePostImage
{
public function __construct(private StorePostImageRequest $request) {}
public function handle(): string
{
// Confirm the exact method names in the package source before copying this.
return \PhpAnonymous\Lynx\Facades\LynxStorage::putFile(
'posts',
$this->request->file('image')
);
}
}
Persist paths, not URLs. Whatever the facade returns, store the relative disk path in the database. Derive URLs at read time (Storage::disk('public')->url($path) or temporaryUrl() for private disks) so switching to S3 later doesn't require a data migration.
Validate uploads in a FormRequest, not the generated controller. The generator's validation is schema-shaped and rarely covers MIME/size. Add explicit rules and keep them out of the generated file:
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'image' => ['required', 'file', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
];
}
Layer customizations outside the generated files. Keep business logic in a service or action class, and validation in FormRequests, so the generated controller stays a thin shell you can regenerate safely:
// app/Http/Controllers/Api/PostController.php (generated)
public function store(StorePostRequest $request, CreatePost $createPost)
{
return new PostResource($createPost->handle($request->validated()));
}
Shape output with API Resources. Generated controllers typically return models or raw arrays. Wrap responses in JsonResource / ResourceCollection so your contract is decoupled from the schema — critical if the package emits return $model; directly, and even more so when a stored file path leaks into the payload verbatim.
Pair with Sanctum and policies. Generated routes almost never ship with authorization. This matters more for uploads: an unauthenticated or unauthorized POST with a file is an unbounded storage-write primitive. Add $this->authorize() or policy middleware before exposing anything beyond a dev environment.
"phpanonymous/lynx": "1.0.20"), and check the composer.json illuminate/* constraints against your Laravel version before upgrading. The new storage facade in 1.0.20 is exactly the kind of addition that can shift between patch releases — pin harder, not looser.src/Facades/ and its resolved class before writing against it, and don't assume it honours config/filesystems.php disks, visibility, or Storage::fake() in tests.storage/app/private when you expected public, the facade is likely defaulting to a disk name from config/lynx.php. Publish the config, set the disk explicitly, then php artisan config:clear.php artisan storage:link is not optional. Uploads that succeed but 404 on retrieval are almost always a missing symlink or a public vs private visibility mismatch, not a code bug.destroy or on replace-during-update. Add deletion to your action layer — otherwise storage grows forever and you'll be writing a backfill script later.../../) and .php in a web-served directory become real. Validate with mimes: (content-based) rather than extensions:.upload_max_filesize, post_max_size, and client_max_body_size will reject a 6 MB file with an empty $_FILES payload, and you'll debug your validator instead of your php.ini / nginx config.lynx:generate run overwrites. Commit or stash before re-running, and diff the output. If the package has a --force flag, treat it as destructive — and re-check after any update that the 1.0.20 generator now emits upload code into controllers you'd already customized.{id} patterns. Run php artisan route:list after every generation and check ordering.$fillable and response payloads straight from the schema. That means auto-increment IDs, password, remember_token, soft-delete columns, internal flags — and now the file-path column — can end up writable or visible. Audit $fillable, $hidden, and every Resource before shipping.php artisan config:cache masks config edits. After publishing or editing config/lynx.php, run php artisan config:clear — otherwise your changes silently don't apply and you'll debug the wrong thing.index methods rarely eager-load. Add ->with([...]) or a $with property on the model; verify with Telescope or DB::listen.protected $table explicitly on the model rather than fighting the convention.vendor:publish --tag=lynx-stubs), a config array of model paths and disk names, and any Lynx::macro() or generator-callback hooks in the service provider. Stub overrides are usually the cleanest way to bend output to team conventions without forking — including making the generated upload code use your own action class.php artisan list | grep lynx → route:list → tinker the generated controller's dependencies → dd() inside the generator command to see what it resolved. With a package this small, adding a temporary ray()/dump() in the command's handle() — or in the storage facade's put method — is faster than reading docs that likely don't exist.200 on index, 201 on store, and — new for 1.0.20 — 201 plus a successful Storage::fake() assertion on an upload catches broken routes, guard mismatches, mass-assignment errors, and disk misconfiguration immediately after generation, and gives you a safety net the package doesn't provide.How can I help you explore Laravel packages today?