luperi/page-annotator-bundle
Page Annotator Bundle is a Symfony/PHP package for adding page annotation features to web applications. It lets users attach notes and comments to page content and gives Symfony projects a simple, configurable way to integrate annotation workflows.
Read this before anything else: luperi/page-annotator-bundle is a Symfony bundle, not a Laravel package. There is no extra.laravel block, no service provider, no facade, and no config merge hook that Laravel will run for you. That single fact dictates your first move: you are either (a) working in a Symfony app, where this installs normally, or (b) working in Laravel, where "using" this package means reusing its front-end overlay and copying its persistence shape, not composer require.
If you are on Symfony, install and register it:
composer require luperi/page-annotator-bundle
// config/bundles.php
return [
// ...
Luperi\PageAnnotatorBundle\PageAnnotatorBundle::class => ['all' => true],
];
Then look, in this order, before writing a line of your own code:
src/DependencyInjection/Configuration.php — this is the real documentation; it defines the config tree, defaults, and required keys.src/Resources/config/ — services, routes, and Twig paths; this tells you what wiring the bundle expects.Entity/, Model/, or Repository/ directory — this is where annotation persistence lives and whether it is Doctrine-mapped.composer.json — require and php constraints. If it wants Symfony 3/4 or PHP < 8, stop and reassess before spending more time.If you are on Laravel (the realistic case here): do not add this to composer.json. Instead, in a 1–2 day spike, answer one question — can I extract the JS/CSS overlay assets standalone? Copy them into resources/js/vendor/page-annotator/ and build the backend yourself. A minimal annotations migration, an Annotation model, a controller, and three routes gives you an owned, testable feature in 1–3 days:
Schema::create('annotations', function (Blueprint $table) {
$table->id();
$table->morphs('annotatable'); // annotatable_type / annotatable_id
$table->foreignId('user_id')->constrained();
$table->string('page_key')->index(); // route name or URL
$table->string('selector'); // CSS path to the target DOM node
$table->json('position')->nullable();// x/y/w/h for region annotations
$table->text('body');
$table->timestamps();
});
First use case to ship: one page type (e.g. an internal QA detail page), one vertical path — user selects an element, types a note, it persists, and it re-renders for that page on reload. Do not start with multi-page, threads, or replies.
Persistence shape. The one thing genuinely worth lifting from this bundle is the context captured per annotation: a page identifier (URL or route name), a DOM selector or coordinate pair, the author, and the body. Your Eloquent model should be polymorphic so the same table serves any reviewable resource (a report, a product draft, a client preview):
class Annotation extends Model
{
protected $fillable = ['page_key', 'selector', 'position', 'body'];
protected $casts = ['position' => 'array'];
public function annotatable() { return $this->morphTo(); }
public function user() { return $this->belongsTo(User::class); }
}
API-first, not full-page. The overlay is client-side; the server only needs a small JSON surface. Keep it to three routes under routes/api.php:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/annotations', [AnnotationController::class, 'index']); // ?page_key=...
Route::post('/annotations', [AnnotationController::class, 'store']);
Route::delete('/annotations/{annotation}', [AnnotationController::class, 'destroy']);
});
index should scope by page_key and eager-load the author; store validates selector/body size and sanitizes on write; destroy is gated by a policy (author_id === $request->user()->id).
Bootstrapping the overlay in Blade. Treat the overlay as a page-scoped enhancement, not a global script. Push the script and a single JSON config blob into the layout for authenticated users only:
@auth
@push('scripts')
<script src="{{ asset('vendor/page-annotator/annotator.js') }}"></script>
<script>
PageAnnotator.init({
pageKey: "{{ $pageKey }}",
endpoints: { list: "/api/annotations", create: "/api/annotations" },
csrf: "{{ csrf_token() }}",
});
</script>
@endpush
@endauth
The bundle's own Twig include is the same idea — a template partial plus a page identifier — so if you are on Symfony, drop the {{ include('@PageAnnotator/...') }} tag into the base template and pass through the same identifier convention.
Authorization and tenancy. The bundle has no opinion here; you must add one. The policy is the whole security model:
class AnnotationPolicy
{
public function delete(User $user, Annotation $a): bool
{
return $user->id === $a->user_id || $user->can('moderate-annotations');
}
}
For multi-tenant apps, add a tenant_id (or scope via annotatable) and enforce it in a global scope so no controller query can leak across tenants.
Integration into an existing review flow. The most useful pattern I've seen is to attach the annotator to a "review" model that already exists (a document draft, a design mock, a UAT checklist item) and store the annotations polymorphically. That way you don't invent a new navigation surface — annotations become a side-panel on a page you already have. Emit a domain event (AnnotationCreated) so you can fan out to Slack/Jira later without touching the controller.
If you're forking the Symfony bundle into a Laravel app: strip the DI/HttpKernel coupling, remove Twig templates, replace Doctrine entities with Eloquent models, and hardcode the service construction (no container events). Keep the fork to a few hundred lines or it will rot within two upgrade cycles.
ServiceProvider, no extra.laravel, no config/*.php to publish. If a teammate says "just composer require it," they are looking at the description, not the source. Verify the bundle type in composer.json ("type": "symfony-bundle") before anything else.composer.json require and php constraints in the first five minutes — a php: ">=5.6" or symfony/framework-bundle: "^3.4" constraint is a hard stop for a modern Laravel app.body on write (HTML Purifier or a strict allowlist) and escape on render — do not rely on the package's rendering to do it for you, and be extra suspicious of any field that claims to store "snippet" or "context."init blobs in production.AnnotationRepository interface, that's where you'd swap implementations in Symfony — but you're reimplementing it in Laravel anyway, so model the replacement on that interface so the fork's tests (if any) still make sense.var/log/dev.log for container build errors — a mis-registered bundle or missing config key fails at container compile, not runtime, and the exception message points to Configuration.php. The config tree there is the source of truth for every key the bundle accepts.How can I help you explore Laravel packages today?