Overview
The Lesson Whiteboard lets a tutor draw, highlight, and leave notes on lesson material during a live class. The student sees each mark arrive on their own screen, and after the lesson gets a recap of what the tutor marked for them and which corrections they received.
I built it end to end: the database schema, service, and endpoints in the Laravel API, WebSocket broadcasting over Laravel Reverb, the tutor's board, and the student's live mirror and recap.
The problem
Lessons were taught from PDFs and slide decks, and tutors had no way to mark them up where students could see. Corrections lived in tutors' heads or in chat. Once the lesson ended, the student had nothing to go back to.
The feature had to work across three codebases owned by different parts of the team, for a platform with 20,000+ active students. It also had to work in a real class, not a demo: eight students, a dozen materials, and a 31-page deck.
Architecture
API (api-v2, Laravel). Board annotations extend the existing annotations schema with an event scope next to the exercise scope. The two scopes are mutually exclusive by construction, and the new columns are hidden so the existing exercise endpoints return byte-identical responses. Rectangles use a packed, page-relative wire format shared with the student app's reading-exercise annotations.
Realtime (Laravel Reverb). Each write dispatches a ShouldBroadcastNow event on private-event.{id}.whiteboard: annotation created, updated, or deleted, and focus changed. The channel is authorised in one place. The assigned tutor and the enrolled students may subscribe. Admins and soft-deleted enrolments are refused.
Tutor app (tutor-v3, React + fabric.js). The board draws on a fabric.js canvas layered over the react-pdf page, with per-student annotations persisted through the API.
Student app (academy-v2, React). A live mirror of the board during class, and a post-lesson recap with filters for "On the page" and "Corrections".
Key decisions
A broadcast failure never fails the save
The write is the source of truth; the broadcast is a notification. The service dispatches events only after a successful write, and a broadcaster failure is logged but never turned into an error for the tutor. There's a test for each case: a refused write dispatches nothing, and a broken broadcaster doesn't fail the save.
Broadcast a pointer, not the text
When a tutor logs a correction, the channel carries whiteboard.corrections.changed as a pointer, never the correction text. Every client re-reads its own scoped view through the API. A student never receives another student's corrections, even though the whole class shares a channel.
Geometry belongs to the page
Annotations are stored as percentages of the page, not as screen pixels. That's why the same mark lands in the right place on a tutor's laptop and a student's phone. It also exposed a bug: marks were painted before the PDF had rendered, leaving the tutor's ink floating over blank white for seconds on a heavy page. The overlay now waits for react-pdf's onRenderSuccess, keyed by material and page, so a page turn re-arms it. A regression test holds the page in its unrendered state and asserts that nothing is drawn.
Degraded mode
If the WebSocket isn't live, the student board polls every 10 seconds, so the lesson keeps working. In recap mode the channel is never opened. The first version kept polling there too, re-reading the board all afternoon after the lesson ended. The interval now checks that the lesson is still live before it fires.
Giving the material the board back
At a real class on a 1440×900 laptop, the page the tutor taught from was 257×363 px: 7.2% of the screen. Fixed side rails took 48% of the width, and headers and padding took most of the height.
I moved the materials tray into a header dropdown and floated the corrections panel in the empty space beside a portrait page, so it costs no width. I also measured the stage from its actual container instead of a hard-coded 100vh - 140px. The same page became 537×760 px, 31.5% of the screen: 2.1× wider and 4.4× the area.
Bugs worth telling
- Validation silently dropped a field. A note the tutor stretched came back at the default size on every page turn. The board did send
meta.font_size, but Laravel'svalidated()returns only keys that have a rule, andmeta.colorwas the only rule undermeta.font_sizenow has its own rule, and a regression test checks the round trip through both the response and the database row. - A partial update overwrote the whole object. Changing a note's colour sent
meta: { color }alone, and the API replacesmetawholesale, so the note snapped back to 20 px. The tutor app now sends the fullmeta. - A board that can't exist. The student app offered a Whiteboard button on writing corrections, mock tests, and meetings, where there is no tutor board. The API now returns 422 for those event types, the channel refuses the subscription, and the student app hides the button using the same list of event types the API uses.
Results
- Live annotation and corrections in class, with a recap for students afterwards
- 4.4× more screen area for the material in a real class
- The channel contract, author-only edits, and event-type guards are covered by tests across all three codebases
