Skip to content

Bookmark System ​

Allow users to save items into named collections. Bookmarking is idempotent — bookmarking the same item twice returns the existing bookmark without error.

Overview ​

A bookmark system involves:

  • Add bookmark — save an item to a user's collection (idempotent)
  • Remove bookmark — delete a saved bookmark (404 if not found)
  • List bookmarks — all bookmarks for a user, optionally filtered by collection
  • Count bookmarks — lightweight badge counter
  • Get bookmark — check whether a specific item is bookmarked

Database Schema ​

sql
CREATE TABLE bookmarks (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    item_id    INTEGER NOT NULL,
    collection TEXT    NOT NULL DEFAULT 'default',
    created_at TEXT    NOT NULL,
    UNIQUE (user_id, item_id),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (item_id) REFERENCES items(id)
);

UNIQUE (user_id, item_id) enforces one bookmark per user per item. The collection field groups bookmarks into named categories with 'default' as the fallback.

Idempotent Add ​

Check for an existing bookmark before inserting. On conflict (race condition), catch DatabaseConstraintException and return the existing record:

php
public function add(int $userId, int $itemId, string $collection, string $now): Bookmark
{
    $existing = $this->find($userId, $itemId);

    if ($existing !== null) {
        return $existing;  // already bookmarked — not an error
    }

    try {
        $this->executor->execute(
            'INSERT INTO bookmarks (user_id, item_id, collection, created_at) VALUES (?, ?, ?, ?)',
            [$userId, $itemId, $collection, $now],
        );
    } catch (DatabaseConstraintException) {
        // Race condition — another request won; return the existing bookmark
        $found = $this->find($userId, $itemId);
        if ($found !== null) {
            return $found;
        }
    }

    $id = (int) $this->executor->lastInsertId();
    return new Bookmark($id, $userId, $itemId, $collection, $now);
}

The check-then-insert pattern handles the common case efficiently. The DatabaseConstraintException catch handles the race condition under concurrent requests.

Collection Filtering ​

Use an optional collection query parameter to filter bookmarks:

php
// GET /users/{userId}/bookmarks?collection=reading
$collection = isset($query['collection']) && $query['collection'] !== ''
    ? $query['collection'] : null;

$items = $this->repo->listByUser($userId, $collection);

null collection returns all bookmarks; a non-empty string filters to that collection.

Remove Returns 204 vs 404 ​

  • 204 No Content — bookmark existed and was deleted
  • 404 Not Found — bookmark did not exist
php
$removed = $this->repo->remove($userId, $itemId);

if (!$removed) {
    return $this->responseFactory->create(['error' => 'bookmark not found'], 404);
}

return $this->responseFactory->createEmpty(204);

execute() returns the affected row count — zero means no bookmark was found.

MySQL Schema ​

MySQL requires explicit ENGINE=InnoDB and AUTO_INCREMENT syntax:

sql
CREATE TABLE bookmarks (
    id         INT          NOT NULL AUTO_INCREMENT,
    user_id    INT          NOT NULL,
    item_id    INT          NOT NULL,
    collection VARCHAR(100) NOT NULL DEFAULT 'default',
    created_at DATETIME     NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_user_item (user_id, item_id),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (item_id) REFERENCES items(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

For MySQL integration tests, SET FOREIGN_KEY_CHECKS = 0 before dropping tables to avoid FK dependency order issues.

MySQL Integration Test Pattern ​

php
protected function setUp(): void
{
    $host = (string) (getenv('MYSQL_HOST') ?: '');
    if ($host === '') {
        self::markTestSkipped('MYSQL_HOST not set — skipping MySQL integration tests');
    }
    ...
    $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
    $this->pdo->exec('DROP TABLE IF EXISTS bookmarks');
    $this->pdo->exec('DROP TABLE IF EXISTS items');
    $this->pdo->exec('DROP TABLE IF EXISTS users');
    $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 1');

    $schema = (string) file_get_contents('.../database/schema.mysql.sql');
    $this->pdo->exec($schema);
}

protected function tearDown(): void
{
    if ($this->mysqlEnabled && $this->pdo !== null) {
        $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
        $this->pdo->exec('DROP TABLE IF EXISTS bookmarks');
        $this->pdo->exec('DROP TABLE IF EXISTS items');
        $this->pdo->exec('DROP TABLE IF EXISTS users');
        $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
    }
}

Security Properties ​

PropertyImplementation
One bookmark per user per itemUNIQUE (user_id, item_id) DB constraint
Race condition on addDatabaseConstraintException catch → return existing
User isolationAll queries filter by user_id
Remove non-existentReturns 404 (not silent)

Route Summary ​

MethodPathDescription
POST/usersCreate a user
POST/itemsCreate an item
POST/users/{userId}/bookmarksAdd bookmark (idempotent)
DELETE/users/{userId}/bookmarks/{itemId}Remove bookmark (204 or 404)
GET/users/{userId}/bookmarksList bookmarks (?collection= filter)
GET/users/{userId}/bookmarks/countTotal bookmark count
GET/users/{userId}/bookmarks/{itemId}Get single bookmark status

Released under the MIT License.