This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Learning with Texts (LWT) is a self-hosted web application for language learning by reading. This is a third-party community-maintained fork that improves upon the official SourceForge version with modern PHP support (8.2-8.5), smaller database size, better mobile support, and active development.
Tech Stack:
- Backend: PHP 8.2+ with MySQLi
- Frontend: TypeScript, Alpine.js, Bulma CSS, jQuery (legacy)
- Database: MySQL/MariaDB with InnoDB engine
- Build Tools: Composer (PHP), NPM with Vite (JS/CSS)
git clone https://github.com/HugoFara/lwt
cd lwt
composer install --dev
npm installCopy .env.example to .env and update the database credentials:
cp .env.example .env
# Edit .env with your database credentialsThe .env file contains:
DB_HOST- Database server (default: localhost)DB_USER- Database username (default: root)DB_PASSWORD- Database passwordDB_NAME- Database name (default: learning-with-texts)DB_SOCKET- Optional database socketMULTI_USER_ENABLED- Enable user_id-based data isolation (default: false)
# Docker (recommended for quick setup)
docker compose up # Start app at http://localhost:8010/
# PHP built-in server (for development)
php -S localhost:8000 # Start at http://localhost:8000/# PHP tests
composer test # Run PHPUnit tests with coverage
composer test:no-coverage # Run PHPUnit tests without coverage (faster)
# Run a single test file
./vendor/bin/phpunit tests/backend/Services/TextServiceTest.php
# Run a specific test method
./vendor/bin/phpunit --filter testMethodName
# Integration tests (requires test database)
composer test:setup-db # Create test database and apply migrations
composer test:db-status # Show test database status
composer test:reset-db # Drop and recreate test database
composer test:integration # Run integration tests (sets up DB automatically)
# Frontend tests (Vitest)
npm test # Run all frontend tests
npm run test:watch # Watch mode for frontend tests
npm run test:coverage # Run with coverage
# E2E tests (requires server on localhost:8000)
npm run e2e # Run Cypress E2E tests
npm run cy:open # Interactive Cypress test runnerIntegration Tests: Some tests require a MySQL database with FK constraints. Run composer test:setup-db once to create the test database (test_<dbname> from your .env). The integration test suite includes FK cascade tests, tag service tests, and other database-dependent tests.
Unit Tests and Database Guards: CI runs PHPUnit without a MySQL service, so any unit test that reaches a database call (directly or via static methods like Settings::getWithDefault(), TagsFacade::*, QueryBuilder::table()) will fail on CI. When writing unit tests, add a skip guard to any test method that may hit the database:
if (!defined('LWT_TEST_DB_AVAILABLE') || !LWT_TEST_DB_AVAILABLE) {
$this->markTestSkipped('Database connection required');
}Prefer mocking or restructuring code to avoid DB calls in unit tests. Use the skip guard only when static/global DB calls cannot be avoided (e.g., deeply nested static method calls).
When to run E2E tests: Run npm run e2e after making changes to:
- Routes or URL handling (
src/backend/Router/) - Controllers (
src/backend/Controllers/) - Form handling or navigation
- REST API endpoints
- Fix the test failures, even if they are unrelated to the current changes.
./vendor/bin/psalm # Static analysis (default level)
composer psalm:level1 # Strictest static analysis
npm run lint # ESLint for TypeScript/JS
npm run lint:fix # Auto-fix lint issues
npm run typecheck # TypeScript type checking
./vendor/bin/phpcs [file] # PHP code style check
./vendor/bin/phpcbf [file] # PHP code style auto-fixAfter every PHP file change, always run these checks and fix any issues before committing:
./vendor/bin/psalm --threads=1— Psalm static analysis must pass with 0 errors (multi-thread crashes due to amphp bug; always use--threads=1)./vendor/bin/phpcs --standard=PSR12 [changed files]— PHP CodeSniffer must have 0 errors and 0 warningscomposer test:no-coverage— PHPUnit tests must all pass (run after any important PHP change)
npm run dev # Start Vite dev server with HMR
npm run build # Build Vite JS/CSS bundles
npm run build:themes # Build theme CSS files
npm run build:all # Build everything (Vite + themes)
composer build # Alias for npm run build:allFrontend Development Workflow:
- Run
npm run devfor development with Hot Module Replacement - Run
npm run typecheckto check TypeScript errors - Run
npm run build:allfor production build before committing
composer doc # Regenerate all documentation (VitePress + JSDoc + phpDoc)
composer clean-doc # Clear all generated documentationAll requests route through index.php → Router → Controller → Service → View:
index.phpbootstraps the application and invokes the Routersrc/backend/Router/routes.phpmaps URLs to controller methods- Controllers in
src/backend/Controllers/handle request/response - Services in
src/backend/Services/contain business logic - Views in
src/backend/Views/render HTML output
The codebase has two parallel structures. New feature work should target src/Modules/ when the relevant module exists; src/backend/ is the legacy layer being incrementally migrated.
src/Modules/— New modular architecture with bounded contexts, DI containers, and repository patternsrc/backend/— Legacy MVC layer (Controllers/Services/Views) still handling most routessrc/Shared/— Cross-cutting infrastructure used by both (Database, Http, Container, UI helpers)
Both share the Lwt\ PSR-4 root, with explicit mappings: Lwt\ → src/backend/, Lwt\Shared\ → src/Shared/, Lwt\Modules\ → src/Modules/.
src/Shared/ # Cross-cutting infrastructure
├── Infrastructure/
│ ├── Database/ # Connection, DB, QueryBuilder, PreparedStatement, etc.
│ ├── Http/ # InputValidator, SecurityHeaders, UrlUtilities
│ ├── Container/ # DI Container, ServiceProviders
│ └── Globals.php # Type-safe global state access
├── Domain/
│ └── ValueObjects/ # UserId (cross-module identity)
└── UI/
├── Helpers/ # FormHelper, IconHelper, PageLayoutHelper, etc.
└── Assets/ # ViteHelper
src/Modules/ # Feature modules (bounded contexts)
├── Admin/ # Admin/settings module
├── Dictionary/ # Dictionary lookup/translation module
├── Feed/ # RSS feed module
├── Home/ # Home page/dashboard module
├── Language/ # Language configuration module
├── Review/ # Spaced repetition testing module
├── Tags/ # Tagging module
├── Text/ # Text reading/import module
├── User/ # User authentication module
└── Vocabulary/ # Terms/words module
# Each module follows this structure:
├── Application/ # Use cases and application services
├── Domain/ # Entities, value objects, repository interfaces
├── Http/ # Controllers, request handling
├── Infrastructure/ # Repository implementations, external integrations
├── Views/ # Module-specific view templates
└── [Module]ServiceProvider.php # DI container registration
src/backend/ # Legacy MVC (being migrated to src/Modules/)
├── Controllers/ # MVC Controllers
├── Services/ # Business logic layer
├── Views/ # PHP templates organized by feature
├── Router/ # URL routing (Router.php, routes.php)
├── Api/V1/ # REST API handlers
│ ├── Handlers/ # Endpoint handlers by resource
│ ├── ApiV1.php # Main API router
│ └── Endpoints.php # Endpoint registry
└── View/Helper/ # StatusHelper (business logic dependency)
src/frontend/
├── js/ # TypeScript source (built with Vite)
│ ├── main.ts # Entry point
│ ├── types/ # TypeScript declarations
│ └── *.ts # Feature modules
└── css/
├── base/ # Core styles
└── themes/ # Theme overrides
Key tables (InnoDB engine):
languages- Language configurations (parsing rules, dictionaries)texts/archivedtexts- User texts for readingwords- User vocabulary with status trackingsentences- Parsed sentences from textstextitems2- Word occurrences linking words to sentencessettings- Application settings (key-value pairs)
Word Status Values: 1-5 (learning stages), 98 (ignored), 99 (well-known)
Use Lwt\Shared\Infrastructure\Globals class instead of PHP globals:
use Lwt\Shared\Infrastructure\Globals;
// Database operations
$db = Globals::getDbConnection();
$tableName = Globals::table('words'); // Returns table name
// Query builder
$words = Globals::query('words')->where('WoLgID', '=', 1)->get();
// User context (for multi-user mode)
$userId = Globals::getCurrentUserId();
$userId = Globals::requireUserId(); // Throws if not authenticatedBase URL: /api/v1 (also supports legacy /api.php/v1)
Key endpoint groups (see src/backend/Api/V1/Endpoints.php for full list):
languages- Language CRUD and definitionstexts- Text management and statisticsterms- Vocabulary CRUD, status changes, bulk operationsfeeds- RSS feed managementreview- Spaced repetition test interfacesettings- Application configurationtags- Term and text tagging
- Add route in
src/backend/Router/routes.php - Create/extend controller in
src/backend/Controllers/ - Extract business logic to
src/backend/Services/ - Create view templates in
src/backend/Views/[Feature]/
- Controllers extend
BaseControllerwhich provides helper methods for input validation, rendering, and database access - Use prepared statements for database queries:
Connection::preparedFetchAll($sql, [$param1, $param2]) - For IN clauses with arrays of IDs:
Connection::buildPreparedInClause($ids, $bindings)returns(?,?,?)and appends values to$bindings; returns(NULL)for empty arrays - Use
Globals::table('tablename')for table names - Use
getSettingWithDefault()for application settings - Use
InputValidatorfor request parameter validation (accessed via$this->param(),$this->paramInt()in controllers) - Use
forTablePrepared()instead of legacyforTable()for parameterized queries in module code
Key Namespaces:
- Database:
Lwt\Shared\Infrastructure\Database\{Connection, DB, QueryBuilder} - HTTP:
Lwt\Shared\Infrastructure\Http\{InputValidator, SecurityHeaders} - Container:
Lwt\Shared\Infrastructure\Container\Container - UI Helpers:
Lwt\Shared\UI\Helpers\{FormHelper, PageLayoutHelper, IconHelper}
- Edit files in
src/frontend/js/*.ts - Run
npm run devfor HMR during development - Run
npm run typecheckbefore committing - Run
npm run buildto generate production bundles
Key modules:
pgm.ts- Main program logic and utilitiestext_events.ts- Text reading interfaceaudio_controller.ts- Audio playbacktranslation_api.ts- Translation integration
This project uses @alpinejs/csp (aliased in vite.config.ts), which cannot evaluate inline expressions. The CSP header (script-src 'self' in SecurityHeaders.php) enforces this.
Never do:
x-data="{ foo: 'bar', count: 0 }"— inline object literals@click="count++"or@change="show = ['a','b'].includes($event.target.value)"— complex inline expressions@change="setPerPage(parseInt(value))"— calls to JS globals (parseInt,Number,JSON, etc.) are undefined in CSP eval scope; do the conversion inside the component method insteadx-text="obj?.prop"orx-text="foo?.bar || 'default'"— optional chaining (?.) and other JS syntax beyond simple property access causes CSP parser errors; wrap in a component method insteadx-data="componentName()"with parentheses — function call syntax
Instead:
- Register components via
Alpine.data('name', () => ({ ... }))in TypeScript - Use
x-data="name"(no parentheses) in HTML - Move all logic into component methods:
@click="increment()",@change="updateMode($event)" - Pass config from PHP via
<script type="application/json" id="config-id">and read it in the component'sinit()method
Known violations: Some older views (e.g., edit_form.php) still use inline x-data object literals and simple inline assignments like @click="importMode = 'file'". These work at runtime because @alpinejs/csp actually supports simple property assignments and ternaries — it only breaks on complex expressions like function calls or array methods. New code should still follow the strict pattern above (registered components), but be aware that existing inline patterns may not cause errors.
- Create folder
src/frontend/css/themes/your-theme/ - Add CSS files (missing files fall back to
base/defaults) - Run
npm run build:themesto generate minified themes
- Character Encoding: UTF-8 throughout
- Namespaces: PSR-4 autoloading with
Lwt\prefix - ID Columns:
LgID(language),TxID/AtID(text/archived),WoID(word) - Database Queries: Always use prepared statements (
Connection::preparedFetchAll(),preparedExecute(),preparedFetchValue()). Never interpolate variables into SQL strings. UsebuildPreparedInClause()for IN clauses. - Test Namespaces:
Lwt\Tests\maps totests/backend/
Migration files in db/migrations/ with format YYYYMMDD_HHMMSS_description.sql. The _migrations table tracks applied migrations.
The version must be updated in these files before tagging a release:
| File | What to update |
|---|---|
src/Shared/Infrastructure/ApplicationInfo.php |
VERSION constant (e.g. '3.0.2-fork') and RELEASE_DATE |
package.json |
version field (without -fork suffix, e.g. "3.0.2") |
CHANGELOG.md |
Move [Unreleased] items to a new version section with the release date |
ApplicationInfo.php is the authoritative version — it's what the app displays. Always update it.
Branches:
main- Stable releasesdevelop- Development branch
Before committing:
- Run
composer testand./vendor/bin/psalm - Run
npm run typecheckandnpm run lint - If you modified frontend assets, run
npm run build:all