Blog

  • Advanced Use Cases: Conditional Migrations in Laravel

    Laravel migrations are normally deterministic: every environment receives the same migration files, Laravel runs each pending migration once, and the migrations table records what has completed.

    Real production systems are not always that tidy.

    You may need to introduce a table only when an optional module is enabled, repair databases created before migrations were adopted, use different SQL for PostgreSQL and MySQL, migrate hundreds of tenant databases, or delay a constraint until a large backfill has finished.

    These situations lead developers towards conditional migrations.

    Conditional migrations can be useful, but they can also create a more serious problem than the one they solve: two environments running the same application version with different and undocumented schemas.

    This guide explains the advanced Laravel patterns that make conditional migrations safer, where Laravel 13’s shouldRun() method fits, and when a condition should be replaced by a staged deployment instead.

    The examples use current Laravel 13 syntax. Most schema-guard and connection patterns also work in earlier Laravel versions, but first-class migration skipping and some schema inspection methods depend on the framework version installed in your project.


    Quick Answer

    A conditional migration is a migration whose behaviour depends on a known condition such as:

    • Whether a feature or module is enabled
    • Whether a legacy table or column already exists
    • Which database driver is active
    • Which database connection is being migrated
    • Whether a prerequisite migration or backfill has completed
    • Which tenant database is currently selected

    Use the narrowest mechanism that matches the requirement:

    RequirementRecommended Laravel mechanism
    Do not run this pending migration yetDefine shouldRun(): bool
    Add a column only if it is missingSchema::whenTableDoesntHaveColumn() or an explicit Schema::hasColumn() guard
    Repair known legacy schema differencesInspect the schema, validate the state, then reconcile it
    Use vendor-specific SQLCheck DB::connection()->getDriverName() and keep each branch tested
    Modify a non-default databaseSet the migration $connection or use Schema::connection()
    Migrate separate tenant databasesRun the tenant migration path once per tenant connection
    Change a large table safelyUse expand, backfill, verify and contract across several deployments
    Wait for data cleanup before adding a constraintFail clearly when the prerequisite is unmet; do not silently skip
    Prevent two deployment nodes migrating togetherRun php artisan migrate --isolated --force with a shared lock-capable cache

    The most important rule is:

    A migration condition should represent stable deployment state, not random request-time behaviour.

    Do not base schema creation on the current user, a percentage rollout, a temporary API response, the time of day, or whichever application server happens to run the command.


    What Is a Conditional Migration?

    A normal migration always performs the same change:

    <?php
    
    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    
    return new class extends Migration
    {
        public function up(): void
        {
            Schema::table('users', function (Blueprint $table) {
                $table->string('timezone')->nullable();
            });
        }
    
        public function down(): void
        {
            Schema::table('users', function (Blueprint $table) {
                $table->dropColumn('timezone');
            });
        }
    };

    A conditional migration checks something before deciding whether or how to make the change:

    public function up(): void
    {
        if (! Schema::hasColumn('users', 'timezone')) {
            Schema::table('users', function (Blueprint $table) {
                $table->string('timezone')->nullable();
            });
        }
    }

    The second example looks safer because it will not fail when the column already exists. However, it changes the meaning of the migration.

    If the column exists with the wrong type, length, collation or nullability, the guard silently accepts it. The application may then fail later even though Laravel reports that every migration has run.

    Conditional logic should therefore answer two questions:

    1. Is this an expected alternative state?
    2. If it exists, is it compatible with what the application requires?

    Merely checking that an object exists is not always enough.


    Three Different Meanings of “Skip”

    Many migration bugs happen because three different behaviours are all described as “skipping the migration.”

    1. Skip the Migration Before up() Runs

    Current Laravel migrations may define shouldRun():

    public function shouldRun(): bool
    {
        return (bool) config('features.audit_log_schema');
    }

    When the method returns false, Laravel skips that migration. It is not treated like a completed schema change, so it can be considered again during a later migration run when the condition becomes true.

    Laravel documents shouldRun() under Skipping Migrations.

    This behaviour is suitable when the migration genuinely should remain pending.

    2. Enter up() but Return Without Changing Anything

    An older and still useful pattern is a guard inside up():

    public function up(): void
    {
        if (Schema::hasColumn('users', 'timezone')) {
            return;
        }
    
        Schema::table('users', function (Blueprint $table) {
            $table->string('timezone')->nullable();
        });
    }

    Laravel called the migration successfully, so it records the migration as completed even when the method returned before issuing DDL.

    This is useful for a reconciliation migration: an imported database may already contain the required column, and the migration exists to bring every database to one recorded baseline.

    It is not appropriate when you want Laravel to try again later.

    3. Run the Migration but Choose a Different Branch

    Sometimes every environment must complete the migration, but the implementation differs:

    $driver = DB::connection($this->getConnection())->getDriverName();
    
    match ($driver) {
        'pgsql' => $this->createPostgresIndex(),
        'mysql', 'mariadb' => $this->createMySqlIndex(),
        default => throw new RuntimeException(
            "Unsupported database driver: {$driver}"
        ),
    };

    This is not really a skipped migration. It is a portable migration with explicit database-specific implementations.

    The distinction matters when diagnosing migrate:status, rolling back, adding later migrations and comparing environments.


    Use Case 1: Feature-Gated Migrations with shouldRun()

    Suppose an audit-log module is present in the codebase but will be enabled during a later release window.

    Create a stable configuration value:

    // config/features.php
    
    return [
        'audit_log_schema' => env('AUDIT_LOG_SCHEMA_ENABLED', false),
    ];

    The migration can define shouldRun():

    <?php
    
    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    
    return new class extends Migration
    {
        public function shouldRun(): bool
        {
            return (bool) config('features.audit_log_schema');
        }
    
        public function up(): void
        {
            Schema::create('audit_events', function (Blueprint $table) {
                $table->id();
                $table->foreignId('user_id')->nullable()->index();
                $table->string('event');
                $table->string('auditable_type')->nullable();
                $table->unsignedBigInteger('auditable_id')->nullable();
                $table->json('metadata')->nullable();
                $table->ipAddress('ip_address')->nullable();
                $table->timestamps();
    
                $table->index(
                    ['auditable_type', 'auditable_id'],
                    'audit_events_auditable_index'
                );
            });
        }
    
        public function down(): void
        {
            Schema::dropIfExists('audit_events');
        }
    };

    When This Is Appropriate

    shouldRun() is useful when:

    • A module is deployed before it is activated.
    • A customer-specific installation includes an optional component.
    • A phased infrastructure change requires a schema migration at a controlled time.
    • A Laravel package publishes migrations that only apply when its integration is enabled.

    The Flag Must Be Stable and Global

    A database schema normally applies to the complete application, not to one HTTP request or one user.

    Avoid this type of condition:

    public function shouldRun(): bool
    {
        return Feature::active('new-checkout');
    }

    if new-checkout is a percentage-based or user-scoped feature flag. The result could depend on the current scope, cached resolution or machine running the migration.

    A schema gate should be one of the following:

    • A deployment configuration deliberately set for the whole environment
    • A globally scoped, persistently stored feature state
    • A release manifest or module registry shared by every deployment node
    • An explicit command option processed by a controlled migration runner

    Do not use a random rollout calculation for DDL.

    Configuration Cache Matters

    Production Laravel applications commonly cache configuration. Code should read the condition through config(), not call env() directly inside the migration.

    After changing the environment variable, rebuild the application’s configuration cache before expecting shouldRun() to see the new value:

    php artisan config:cache
    php artisan migrate --isolated --force

    Also verify that every deployment node has the same configuration. A migration should not run on one node merely because its release directory contains a different cached configuration file.


    Use Case 2: Add a Column Only When It Is Missing

    Laravel’s schema builder can inspect tables, columns and indexes.

    Current schema builder methods include:

    MethodPurpose
    Schema::hasTable('users')Check whether a table exists
    Schema::hasColumn('users', 'timezone')Check one column
    Schema::hasColumns('users', ['timezone', 'locale'])Check several columns
    Schema::hasIndex('users', ['email'], 'unique')Check an index by columns and optional type
    Schema::getColumnType('users', 'timezone')Read a column type
    Schema::getColumns('users')Inspect column metadata
    Schema::getIndexes('users')Inspect index metadata
    Schema::getForeignKeys('posts')Inspect foreign-key metadata

    Laravel 13’s API also exposes conditional helpers such as whenTableHasColumn, whenTableDoesntHaveColumn, whenTableHasIndex and whenTableDoesntHaveIndex. See the current Schema Builder API.

    Concise Conditional Helper

    public function up(): void
    {
        Schema::whenTableDoesntHaveColumn(
            'users',
            'timezone',
            function (Blueprint $table) {
                $table->string('timezone', 64)->nullable();
            }
        );
    }

    This is concise and expressive when existence is the only compatibility requirement.

    Explicit Guard with a Clear Failure

    For advanced migrations, an explicit check is often easier to audit:

    public function up(): void
    {
        if (! Schema::hasTable('users')) {
            throw new RuntimeException(
                'Cannot add users.timezone because the users table is missing.'
            );
        }
    
        if (Schema::hasColumn('users', 'timezone')) {
            $type = Schema::getColumnType('users', 'timezone');
    
            if ($type !== 'varchar') {
                throw new RuntimeException(
                    "Expected users.timezone to be varchar; found {$type}."
                );
            }
    
            return;
        }
    
        Schema::table('users', function (Blueprint $table) {
            $table->string('timezone', 64)->nullable();
        });
    }

    The exact type names returned by schema inspection can vary by database platform and framework version. Test type assertions against every supported driver instead of assuming MySQL and PostgreSQL return identical strings.

    Guard the Rollback Too

    If the up() method tolerates an already-existing column, the down() method requires careful thought.

    This rollback is dangerous:

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('timezone');
        });
    }

    The migration may have found a column created manually years earlier. Rolling back would delete a column the migration did not create.

    For a legacy reconciliation migration, an intentionally irreversible rollback may be safer:

    public function down(): void
    {
        throw new RuntimeException(
            'This reconciliation migration cannot be reversed safely.'
        );
    }

    Another option is to create a separate, explicit removal migration after confirming ownership and taking a backup.

    Do not pretend every production schema change has a safe automatic reverse operation.


    Use Case 3: Repair Inconsistent Legacy Databases

    Conditional migrations are often introduced when an application existed before its schema was fully managed by Laravel.

    For example:

    • Production has a manually created customer_code column.
    • Staging has the same column but with a different length.
    • New developer databases do not have the column.
    • One customer’s self-hosted installation uses a different index name.

    The wrong approach is to add if (! Schema::hasColumn(...)) everywhere until migrations stop failing. That hides drift instead of repairing it.

    Use a reconciliation migration with an explicit state table:

    Observed stateAction
    Table missingStop; a prerequisite or database selection is wrong
    Column missingAdd the expected column
    Column exists and is compatibleRecord migration as complete without changing it
    Column exists but is incompatibleStop with a diagnostic error or convert it through a reviewed branch
    Unexpected duplicate indexStop and inspect before deleting anything

    Example:

    <?php
    
    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    use RuntimeException;
    
    return new class extends Migration
    {
        public function up(): void
        {
            if (! Schema::hasTable('customers')) {
                throw new RuntimeException(
                    'Expected the customers table before reconciling customer_code.'
                );
            }
    
            if (! Schema::hasColumn('customers', 'customer_code')) {
                Schema::table('customers', function (Blueprint $table) {
                    $table->string('customer_code', 32)->nullable();
                });
    
                return;
            }
    
            $column = collect(Schema::getColumns('customers'))
                ->firstWhere('name', 'customer_code');
    
            if ($column === null) {
                throw new RuntimeException(
                    'customer_code exists but its metadata could not be inspected.'
                );
            }
    
            if (($column['nullable'] ?? null) !== true) {
                throw new RuntimeException(
                    'customers.customer_code must be nullable before this release.'
                );
            }
        }
    
        public function down(): void
        {
            throw new RuntimeException(
                'Legacy schema reconciliation is intentionally irreversible.'
            );
        }
    };

    This migration does not silently rewrite an unknown column. It makes a known missing change, accepts a known compatible state and fails on an unexpected state.

    That failure is useful. It prevents the deployment from claiming success while the database remains incompatible.


    Use Case 4: Database-Driver-Specific Migrations

    Laravel’s schema builder is database-agnostic for common operations, but not every database offers the same features or DDL behaviour.

    Common differences include:

    • PostgreSQL partial indexes
    • PostgreSQL CONCURRENTLY
    • MySQL generated columns and online DDL options
    • SQL Server filtered or online indexes
    • SQLite limitations during table alteration
    • Different JSON, enum, collation and full-text behaviour

    Detect the Active Driver

    use Illuminate\Support\Facades\DB;
    
    $driver = DB::connection($this->getConnection())->getDriverName();

    Use an explicit match statement and fail on unsupported databases:

    public $withinTransaction = false;
    
    public function up(): void
    {
        $connection = DB::connection($this->getConnection());
        $driver = $connection->getDriverName();
    
        match ($driver) {
            'pgsql' => $connection->statement(
                'CREATE INDEX CONCURRENTLY IF NOT EXISTS '
                .'orders_open_created_at_index '
                .'ON orders (created_at) '
                .'WHERE completed_at IS NULL'
            ),
    
            'mysql', 'mariadb' => Schema::table(
                'orders',
                function (Blueprint $table) {
                    if (! Schema::hasIndex(
                        'orders',
                        ['completed_at', 'created_at']
                    )) {
                        $table->index(
                            ['completed_at', 'created_at'],
                            'orders_completed_created_index'
                        );
                    }
                }
            ),
    
            default => throw new RuntimeException(
                "Unsupported database driver: {$driver}"
            ),
        };
    }

    Laravel’s base migration class exposes $withinTransaction, which controls whether Laravel wraps a migration in a transaction when the database grammar supports transactional schema changes. Its current API is documented in Illuminate\Database\Migrations\Migration.

    PostgreSQL does not allow CREATE INDEX CONCURRENTLY inside a normal transaction, which is why that migration opts out. Once transaction wrapping is disabled, partial failure becomes more important: inspect the resulting index before retrying.

    Laravel 13 Online Index Creation

    Laravel 13 can express online index creation for PostgreSQL and SQL Server:

    Schema::table('orders', function (Blueprint $table) {
        $table->index('created_at')->online();
    });

    For PostgreSQL, Laravel emits CONCURRENTLY; for SQL Server, it emits the platform’s online option. The current behaviour and supported databases are documented under Online Index Creation.

    Do not add online() to a cross-database migration without testing each supported connection. MySQL online DDL has different syntax and operational restrictions.

    Prefer Capability Checks Over Environment Names

    This is fragile:

    if (app()->environment('production')) {
        // PostgreSQL SQL
    }

    Production may change database vendors, and staging should normally reproduce production behaviour.

    Check the capability that actually matters—the driver, server version, existing index or installed extension—and fail clearly when it is unsupported.


    Use Case 5: Non-Default Database Connections

    A migration can target a named connection by setting $connection:

    return new class extends Migration
    {
        protected $connection = 'analytics';
    
        public function up(): void
        {
            Schema::connection('analytics')->create(
                'daily_metrics',
                function (Blueprint $table) {
                    $table->date('metric_date');
                    $table->string('metric_name');
                    $table->decimal('metric_value', 18, 4);
    
                    $table->primary(['metric_date', 'metric_name']);
                }
            );
        }
    
        public function down(): void
        {
            Schema::connection('analytics')->dropIfExists('daily_metrics');
        }
    };

    Laravel documents both the migration $connection property and Schema::connection() in its migration documentation.

    Avoid Cross-Database Atomicity Assumptions

    A migration that updates two different database connections is not automatically one atomic transaction:

    DB::connection('mysql')->table('users')->update(...);
    DB::connection('analytics')->table('identities')->insert(...);

    The first operation may commit while the second fails. Normal Laravel transactions do not create a distributed transaction across unrelated connections.

    Prefer:

    1. One migration path per owned database.
    2. A resumable synchronization command or job.
    3. A checkpoint table recording copied ranges.
    4. Reconciliation queries comparing both sides.
    5. An explicit cutover after verification.

    Conditional connection logic should choose the correct database; it should not disguise a distributed data migration as one atomic schema change.


    Use Case 6: Optional Modules and Package Migrations

    A reusable application may contain optional modules such as:

    • Audit logging
    • Billing
    • Inventory
    • SAML authentication
    • Search analytics
    • Customer-specific integrations

    There are two clean patterns.

    Pattern A: Separate Migration Paths

    Store module migrations separately:

    database/migrations/
    database/migrations/modules/billing/
    database/migrations/modules/audit/

    Run only the selected module path:

    php artisan migrate \
        --path=database/migrations/modules/audit \
        --force

    This is explicit in the deployment pipeline and works well when installations intentionally have different products.

    Pattern B: Published Package Migrations

    Laravel packages can publish migration files with publishesMigrations():

    public function boot(): void
    {
        $this->publishesMigrations([
            __DIR__.'/../database/migrations' => database_path('migrations'),
        ]);
    }

    Laravel updates published migration timestamps so they are ordered correctly. See the official Package Development documentation.

    Once a package migration has been published and run, do not change that old migration in a later package version. Create a new migration just as you would in the host application.

    Do Not Let Module Conditions Become Invisible

    If two installations intentionally use different module schemas, record that fact outside the migration code:

    • Keep a module registry table.
    • Store the enabled module set in deployment configuration.
    • Include it in support diagnostics.
    • Test upgrades from every supported module combination.
    • Ensure backups and restore procedures include module-owned tables.

    Without an inventory, support engineers cannot tell whether a missing table is intentional or broken.


    Use Case 7: Multi-Tenant Database Migrations

    Multi-tenancy requires two very different designs.

    Shared Database, Shared Schema

    If every tenant shares the same tables and rows are separated by tenant_id, the schema is global.

    Do not conditionally add a column for only one tenant. Adding a column changes the table for every tenant even if only one tenant’s code uses it.

    Use runtime feature flags, tenant configuration or a separate extension table instead.

    Separate Database Per Tenant

    If every tenant has a separate database, each database needs its own migration history.

    A controlled Artisan command can select each database and run a dedicated tenant migration path:

    <?php
    
    namespace App\Console\Commands;
    
    use App\Models\Tenant;
    use Illuminate\Console\Command;
    use Illuminate\Support\Facades\Artisan;
    use Illuminate\Support\Facades\Config;
    use Illuminate\Support\Facades\DB;
    
    class MigrateTenants extends Command
    {
        protected $signature = 'tenants:migrate
            {--tenant=* : Tenant IDs to migrate}
            {--force : Run without a production confirmation}';
    
        protected $description = 'Run pending migrations for tenant databases';
    
        public function handle(): int
        {
            $query = Tenant::query()->where('status', 'active');
    
            if ($ids = $this->option('tenant')) {
                $query->whereIn('id', $ids);
            }
    
            $failed = [];
    
            $query->orderBy('id')->each(function (Tenant $tenant) use (&$failed) {
                Config::set(
                    'database.connections.tenant.database',
                    $tenant->database_name
                );
    
                DB::purge('tenant');
    
                $exitCode = Artisan::call('migrate', [
                    '--database' => 'tenant',
                    '--path' => 'database/migrations/tenant',
                    '--force' => (bool) $this->option('force'),
                ]);
    
                if ($exitCode !== self::SUCCESS) {
                    $failed[] = $tenant->id;
                }
    
                $this->output->write(Artisan::output());
            });
    
            DB::purge('tenant');
    
            if ($failed !== []) {
                $this->error(
                    'Failed tenant IDs: '.implode(', ', $failed)
                );
    
                return self::FAILURE;
            }
    
            return self::SUCCESS;
        }
    }

    This is a simplified example. A production tenant migrator should also provide:

    • A distributed lock so two operators cannot migrate the same tenant together
    • An allowlist or trusted registry for database names and connection details
    • Per-tenant start, finish, duration and error logs
    • Retry policy that does not repeat already successful migrations
    • A canary group before the complete tenant fleet
    • Batching so hundreds of tenants do not overload the database server
    • A report of pending migrations by tenant
    • A way to pause on excessive failure rate
    • Backups or snapshots suitable for the hosting model

    Do not place one large foreach loop inside a schema migration and switch connections invisibly. Tenant orchestration is an operational command; individual tenant migrations should remain normal and auditable.


    Use Case 8: Conditional Indexes and Constraints

    Indexes and constraints often require stronger checks than columns.

    Add an Index Only When Missing

    public function up(): void
    {
        Schema::whenTableDoesntHaveIndex(
            'orders',
            ['account_id', 'created_at'],
            function (Blueprint $table) {
                $table->index(
                    ['account_id', 'created_at'],
                    'orders_account_created_index'
                );
            }
        );
    }

    An explicit version is helpful when index names differ across legacy databases:

    if (! Schema::hasIndex('orders', ['account_id', 'created_at'])) {
        Schema::table('orders', function (Blueprint $table) {
            $table->index(
                ['account_id', 'created_at'],
                'orders_account_created_index'
            );
        });
    }

    Check the columns and uniqueness type, not only a conventional name. Two indexes can have different names but provide the same column ordering.

    Do Not Silently Skip a Required Constraint

    Suppose orders.account_id must become non-null after a backfill.

    This is unsafe:

    if (! DB::table('orders')->whereNull('account_id')->exists()) {
        // Add the constraint.
    }

    If bad rows remain, the migration does nothing but can still be recorded as complete.

    Fail clearly instead:

    public function up(): void
    {
        $missing = DB::table('orders')
            ->whereNull('account_id')
            ->count();
    
        if ($missing > 0) {
            throw new RuntimeException(
                "Cannot require orders.account_id; {$missing} rows are null."
            );
        }
    
        Schema::table('orders', function (Blueprint $table) {
            $table->unsignedBigInteger('account_id')
                ->nullable(false)
                ->change();
        });
    }

    This is a precondition, not an optional feature. A failed precondition should stop the release and explain what must be completed.


    Use Case 9: Large Data Backfills

    Developers sometimes make a migration conditional on table size:

    if (DB::table('orders')->count() < 100000) {
        // Backfill here.
    }

    This produces the opposite of a controlled deployment. Small databases receive the change while large, important databases silently do not.

    For large changes, separate schema evolution from data movement.

    Phase 1: Expand the Schema

    Add a nullable column that old code can ignore:

    Schema::table('orders', function (Blueprint $table) {
        $table->string('status_v2', 32)->nullable()->index();
    });

    Deploy code that can read the old representation and dual-write both representations.

    Phase 2: Backfill in Resumable Batches

    Use an Artisan command or queue jobs rather than one long schema migration:

    DB::table('orders')
        ->whereNull('status_v2')
        ->orderBy('id')
        ->chunkById(1000, function ($orders) {
            foreach ($orders as $order) {
                DB::table('orders')
                    ->where('id', $order->id)
                    ->whereNull('status_v2')
                    ->update([
                        'status_v2' => match ($order->legacy_status) {
                            0 => 'pending',
                            1 => 'paid',
                            2 => 'cancelled',
                            default => 'unknown',
                        },
                    ]);
            }
        });

    Laravel recommends chunkById() when records are being updated during iteration because ordinary chunk offsets can produce inconsistent results. See the Query Builder documentation.

    The extra whereNull() on the update makes the operation restartable and avoids overwriting a newer dual-written value.

    For higher throughput, perform set-based updates where the database can express the transformation safely. The row-by-row example prioritises readability.

    Phase 3: Verify

    Check at least:

    • Remaining null or invalid values
    • Counts grouped by old and new state
    • Records changed after the backfill began
    • Application error rate
    • Query latency and database locks
    • A sample of business-critical records

    Phase 4: Switch Reads

    Deploy code that treats status_v2 as authoritative. Keep the old field temporarily if rollback requires it.

    Phase 5: Contract

    In a later release, add the non-null constraint and eventually remove the old field.

    The condition belongs in release orchestration: “Has the backfill and verification completed?” It should not be an invisible if that produces different final schemas.


    Avoid Eloquent Models Inside Old Migrations

    This may work today:

    Order::query()->whereNull('status_v2')->each(...);

    It may fail next year when a new developer creates a database from scratch.

    The current Order model could then contain:

    • Global scopes that expect columns not yet created
    • Attribute casts incompatible with old data
    • Observers that dispatch jobs or external calls
    • Relationships depending on later tables
    • Events and accessors that did not exist when the migration was written

    Prefer the query builder and explicit table names in migrations:

    DB::table('orders')->whereNull('status_v2')->update(...);

    For a large or operationally sensitive data change, put the backfill in a versioned command or job. Keep the schema migration focused on DDL and precondition checks.


    Environment-Conditional Migrations: Usually a Warning Sign

    This pattern is common:

    if (app()->environment('production')) {
        Schema::create('reporting_snapshots', ...);
    }

    It creates a production-only schema that staging and automated tests cannot reproduce.

    Problems include:

    • Bugs appear only during the production migration.
    • Developers cannot create a faithful local database.
    • migrate:fresh does not produce the same structure everywhere.
    • Later migrations may assume the table exists and fail outside production.
    • A renamed environment such as prod or production-blue can change behaviour.

    Better options are:

    • Use the same schema everywhere and disable behaviour with a runtime flag.
    • Put infrastructure-specific migrations in an explicit path.
    • Target a named database connection.
    • Test production-specific database capabilities in a matching staging database.
    • Keep local substitutes for external warehouses or analytics systems.

    An environment condition is defensible only when schema divergence is an intentional product requirement and is documented, inventoried and tested.


    Conditions That Should Never Control a Migration

    Avoid conditions based on:

    The Current User

    Artisan migration commands do not have a meaningful authenticated web user, and database schema is normally global.

    Random Percentage Rollouts

    A 10% feature flag must not create a table with a 10% probability.

    Current Traffic or Load Alone

    If load is too high, pause the release through orchestration. Do not let the migration report success without performing the required change.

    A Remote API That Can Temporarily Fail

    A timeout should not determine the permanent schema. Resolve remote configuration before the deployment and store the decision locally.

    The Presence of Application Data Without a Defined State Model

    “Run only when there are users” creates different schemas for new and established databases. Define a precondition or backfill phase instead.

    Hostname or Pod Name

    Choose one migration runner explicitly. Do not make schema behaviour depend on an ephemeral server name.


    Safe Production Deployment Pattern

    Conditional migrations still require a controlled deployment process.

    1. Review the Generated SQL

    Laravel can display the SQL without applying it:

    php artisan migrate --pretend

    --pretend is useful for review, but it is not proof that the migration will complete within the required time or locking budget.

    2. Check Migration Status

    php artisan migrate:status

    Record the pending migrations in the deployment evidence.

    3. Back Up and Test Restore

    A backup is only useful if the team can restore it within the application’s recovery target. For destructive or long-running DDL, consider a database snapshot, replica or point-in-time recovery position.

    4. Deploy Backward-Compatible Code

    Old and new application instances may overlap during a rolling or zero-downtime deployment. The schema change must be safe for both versions.

    Use the expand-and-contract sequence:

    1. Add compatible schema.
    2. Deploy code that supports both states.
    3. Backfill and verify.
    4. Switch reads and writes.
    5. Remove old schema in a later release.

    5. Run One Migration Process

    For multi-server deployments:

    php artisan migrate --isolated --force

    Laravel’s --isolated option obtains an atomic lock through the configured cache driver. Other migration commands that cannot obtain the lock do not run, but Laravel documents that they still exit successfully. See Isolating Migration Execution.

    This produces two operational requirements:

    • Deployment nodes must use the same lock-capable cache store.
    • A successful shell exit does not prove that a particular node ran the migrations.

    Prefer one designated migration job, and verify migrate:status afterwards.

    6. Monitor Database and Application Health

    Watch:

    • Lock waits and blocked queries
    • Replication lag
    • CPU, I/O and temporary space
    • Migration duration
    • Error rate and failed jobs
    • Query latency
    • Business-level correctness metrics

    7. Restart Long-Lived Workers

    Laravel queue workers and Octane processes keep booted application state in memory. Restart or reload them according to the deployment platform after compatible schema and code are active.


    Rollback Versus Roll-Forward

    down() is valuable during development, but a production rollback is not always equivalent to restoring the previous system.

    Consider a migration that adds a column, deploys code that writes data into it, and then runs for two hours. Dropping that column during rollback destroys the new data.

    Before deployment, classify the change:

    ChangeTypical recovery strategy
    Add nullable columnRoll back application code; keep harmless column
    Add indexDrop index if safe and necessary
    Rename columnPrefer compatibility phase; do not depend on immediate rollback
    Backfill transformed dataKeep original field until verification period ends
    Add non-null constraintRemove constraint if old code still writes nulls
    Drop column or tableRestore from backup or roll forward; automatic rollback may be impossible
    Cross-database data moveReconcile and resume rather than assume atomic rollback

    For many production failures, a roll-forward migration is safer than attempting to run a destructive down() method under pressure.

    Conditional down() methods must not hide uncertainty. If the migration cannot know whether it owns an existing object, fail with a clear message and require an explicit recovery procedure.


    Testing Conditional Migrations

    A conditional migration needs more tests than a normal migration because it has more possible starting states.

    Test the Empty Database Path

    A new database must reach the expected final schema:

    php artisan migrate:fresh --env=testing

    Never run migrate:fresh against a database containing valuable data. It drops all tables before recreating them.

    Test Every Supported Starting State

    For a guarded column migration, create tests for:

    1. Table exists and column is missing.
    2. Table and compatible column already exist.
    3. Table is missing unexpectedly.
    4. Column exists with an incompatible definition.
    5. Migration is retried after partial non-transactional DDL.
    6. Rollback is attempted after each successful path.

    Test Both Flag Values

    For shouldRun():

    • Run with the stable flag disabled and confirm no object is created.
    • Enable the flag and run migrations again.
    • Confirm the object is created exactly once.
    • Run migrations once more and confirm there is no further change.

    Test the Real Database Engines

    SQLite is useful for fast tests but does not reproduce all MySQL, MariaDB, PostgreSQL or SQL Server DDL behaviour.

    If production uses PostgreSQL and the migration has a PostgreSQL branch, run it against the supported PostgreSQL version in CI or a pre-production environment.

    Verify the Result, Not Only the Exit Code

    Assertions should inspect:

    • Tables and columns
    • Column type, nullability and default
    • Index columns, order and uniqueness
    • Foreign keys and actions
    • Expected row counts or reconciliation totals
    • Pending migration status

    An exit code of zero says that Laravel did not report an error. It does not prove the schema matches the application’s contract.


    Common Mistakes

    1. Wrapping Every Migration in hasTable()

    if (! Schema::hasTable('orders')) {
        Schema::create('orders', ...);
    }

    For a normal application-created table, “already exists” may indicate a duplicate migration, wrong database, manual change or naming collision. Automatically accepting it can mask the root cause.

    Use this guard only for a documented alternative state.

    2. Checking Only a Column Name

    A price column can exist as an integer in one database and a decimal in another. Inspect the attributes that matter to the application.

    3. Using shouldRun() for a Required Prerequisite

    If a constraint is required for the release, do not leave it pending silently. Fail with a diagnostic message when the backfill is incomplete.

    4. Using a User-Scoped Feature Flag

    Schemas are normally environment-wide. Use a global deployment decision.

    5. Performing Millions of Updates in up()

    Long transactions, locks, timeouts and unresumable failures make this dangerous. Use a resumable backfill command.

    6. Editing a Migration That Already Ran

    Laravel will not rerun it merely because its file changed. Create a new migration.

    7. Assuming --isolated Means This Node Migrated

    Another node may own the lock, while this command exits successfully without running. Use one migration runner and verify status.

    8. Assuming Every DDL Operation Rolls Back

    Transaction support differs by database and operation. Test failure halfway through the migration.

    9. Catching Every Exception and Continuing

    This is especially dangerous:

    try {
        Schema::table(...);
    } catch (Throwable $exception) {
        // Ignore it.
    }

    Laravel may record the migration as successful while the schema change failed. Catch only errors you can identify, validate and recover from; otherwise let the deployment stop.

    10. Making Production the First Real Test

    Database-specific branches and online DDL must be exercised against production-like engine versions and realistic table sizes.


    A Practical Decision Framework

    Before adding an if statement to a migration, ask these questions.

    Question 1: Should Every Installation Have the Same Final Schema?

    • Yes: prefer a deterministic migration. Use conditions only to reconcile known starting states.
    • No: document the supported schema variants and how the module or tenant model selects them.

    Question 2: Should Laravel Try Again Later?

    • Yes: use shouldRun() with a stable global condition.
    • No: enter up(), validate the current state, reconcile it and let Laravel record completion.

    Question 3: Is the Condition Optional or Required?

    • Optional feature: skipping may be valid.
    • Required prerequisite: fail clearly rather than skip.

    Question 4: Is the Difference About Capability?

    • Different database driver or version: branch on the capability and test every branch.
    • Different environment name: reconsider the design.

    Question 5: Does the Change Move Large Amounts of Data?

    • Yes: separate the schema migration from a resumable backfill.
    • No: a small deterministic data update may be acceptable, preferably through the query builder.

    Question 6: Can Old and New Code Run Together?

    • No: redesign the migration as expand and contract or use a planned maintenance window.
    • Yes: define how long compatibility must remain.

    Question 7: What Happens Halfway Through?

    Know whether DDL is transactional, which objects may already exist on retry, how to verify the state and whether recovery is roll-forward or rollback.


    Production Checklist

    Before approving a conditional migration, confirm that:

    • The condition represents a documented, expected state.
    • The condition is stable for the complete migration run.
    • Every deployment node sees the same global configuration.
    • shouldRun() is used only when the migration should remain pending.
    • An up() guard is used intentionally when a no-op should still be recorded.
    • Existing objects are checked for compatibility, not only existence.
    • Unsupported database drivers fail clearly.
    • Every database-specific branch is tested on the real engine.
    • Large backfills are resumable and separate from DDL.
    • Required prerequisites stop the migration instead of silently skipping.
    • The migration does not depend on current Eloquent models or observers.
    • Old and new application versions can coexist with the expanded schema.
    • The down() method will not delete an object the migration did not create.
    • The team has a backup, restore plan and recovery decision.
    • php artisan migrate --pretend has been reviewed.
    • php artisan migrate:status is checked before and after deployment.
    • Only one controlled runner executes production migrations.
    • --isolated uses a shared cache driver that supports atomic locks.
    • Monitoring covers locks, replication lag, errors and business correctness.
    • Long-lived queue and application workers are restarted or reloaded.
    • The final schema is verified directly after the command succeeds.

    Frequently Asked Questions

    What is a conditional migration in Laravel?

    It is a migration whose execution or implementation depends on a condition, such as an enabled module, an existing schema object, the active database driver, a selected connection or a completed prerequisite.

    How do I skip a migration conditionally in Laravel 13?

    Define a public shouldRun(): bool method on the migration. Laravel calls it before running the migration. Return true when the migration should execute and false when it should be skipped.

    What is the difference between shouldRun() and returning early from up()?

    shouldRun() prevents the migration from running and leaves it available for a later attempt. Returning early from up() completes the migration call without schema work, so Laravel records it as run. Use the first for deferred execution and the second for deliberate reconciliation.

    Should every migration use Schema::hasTable()?

    No. For normal deterministic migrations, an unexpected existing table should usually stop the process so the cause can be investigated. Existence guards are appropriate only for documented legacy or optional states.

    Can I check whether a column exists before adding it?

    Yes. Use Schema::hasColumn() or Schema::whenTableDoesntHaveColumn(). For legacy databases, also validate important attributes such as type, nullability, default and indexes.

    Can a migration use a feature flag?

    Yes, but the flag must represent a stable global deployment decision. Do not use a user-scoped, random or percentage-based flag to control database schema.

    Should I use app()->environment() inside a migration?

    Usually no. It creates schema differences that automated tests and staging may not reproduce. Prefer a named connection, explicit migration path, capability check or runtime feature flag.

    How do I run different SQL for MySQL and PostgreSQL?

    Use DB::connection()->getDriverName(), branch explicitly, test both paths and throw an exception for unsupported drivers. Use Laravel’s schema builder where it expresses the required operation correctly.

    Can conditional migrations make a migration idempotent?

    They can make specific operations safe to retry, but a few hasColumn() checks do not prove the complete migration is idempotent. You must account for indexes, constraints, data changes and partial DDL failure.

    Should I put a large data backfill inside a migration?

    Normally no. Add compatible schema first, backfill through a resumable command or jobs, verify the result, switch application reads and then add constraints or remove old fields in later migrations.

    Should a migration silently wait when data is not ready?

    If the schema change is required for the release, no. Throw a clear exception stating which prerequisite remains incomplete. Silent no-ops can be recorded as completed and leave the application unprotected.

    How do I migrate separate tenant databases?

    Use a controlled orchestration command that selects each tenant connection and runs a dedicated migration path. Keep a migration history per tenant database, log results and use locks to prevent concurrent runs.

    Is php artisan migrate --isolated enough for multi-server deployment?

    It prevents concurrent migration execution when every node shares a cache store that supports atomic locks. However, a node that does not obtain the lock can still exit successfully. Use one designated migration job and verify pending status afterwards.

    Are migration rollbacks always safe?

    No. Dropping a newly used column can destroy data, and some database DDL is not transactional. For additive changes, rolling application code back while keeping the column is often safer. Plan the recovery method before deploying.

    Can I edit an old migration to add a new condition?

    Do not edit a migration that has already been shared or deployed. Existing databases will not rerun it. Create a new reconciliation or corrective migration.


    Final Summary

    Conditional migrations are most valuable when they make an expected schema difference explicit.

    Laravel provides several useful mechanisms:

    • shouldRun() for a migration that should remain pending until a stable condition becomes true
    • hasTable(), hasColumn(), hasIndex() and metadata inspection for known legacy states
    • Conditional schema helpers for concise, guarded changes
    • Named connections for separate databases
    • Driver detection for database-specific SQL
    • $withinTransaction for operations whose transaction requirements differ
    • --isolated to prevent concurrent production migration runners

    The framework cannot decide whether a condition represents a legitimate variant or hidden schema drift. That remains an architectural decision.

    Use conditions to select between states you deliberately support. Fail when an unexpected state would violate the application’s database contract. For large or breaking changes, avoid a clever one-file migration and use a staged sequence: expand the schema, deploy compatible code, backfill, verify, switch traffic and contract later.

    The safest conditional migration is not the one with the most defensive if statements. It is the one whose starting states, final state, retry behaviour and recovery path are all known before production runs it.

  • Web Scraping for News Data Acquisition in Stock Market Prediction AI

    Historical prices show what the market did. News can help explain what investors knew, when they knew it and why expectations changed.

    For a stock-market prediction project, useful news data may include:

    • Earnings announcements
    • Product launches and recalls
    • Management changes
    • Mergers and acquisitions
    • Regulatory decisions
    • Lawsuits and investigations
    • Analyst upgrades and downgrades
    • Interest-rate and inflation news
    • Supply-chain disruptions
    • Industry-specific events

    However, collecting news is not as simple as copying headlines into a CSV file. A reliable system must identify relevant articles, respect website rules, preserve accurate timestamps, remove duplicates and avoid using information that was unavailable at the time of prediction.

    This tutorial builds a practical Python news-acquisition pipeline for an AI stock-prediction project. It uses RSS feeds for discovery, permitted webpage extraction for full article data, robots.txt checks, request throttling, structured metadata, deduplication and CSV/JSONL output.

    The tutorial also explains when a news API is a better choice than web scraping and how to prepare the collected records for sentiment analysis or other natural-language-processing models.

    This project is for educational and research purposes. Website terms, copyright rules, database rights and data-licensing requirements vary by source and jurisdiction. Check the applicable rules before collecting or redistributing content.


    Quick Answer

    The safest practical order for collecting financial news is:

    1. Use a licensed data feed or official API when one is available.
    2. Use the publisher’s RSS feed for article discovery.
    3. Scrape public HTML only when the site’s rules and terms permit it.
    4. Identify your crawler and limit its request rate.
    5. Store the original URL, source, publication time and collection time.
    6. Deduplicate syndicated and updated stories.
    7. Align each article with the first trading period in which it could have been known.
    8. Keep raw data unchanged and create cleaned data as a separate dataset.

    A useful news record should look like this:

    {
      "source": "Example Financial News",
      "url": "https://example.com/news/company-results",
      "canonical_url": "https://example.com/news/company-results",
      "title": "Example Company Reports Quarterly Results",
      "published_at": "2026-08-05T08:30:00+00:00",
      "collected_at": "2026-08-05T08:34:12+00:00",
      "author": "News Desk",
      "description": "The company reported its latest quarterly results.",
      "text": "Full permitted article text or an authorised excerpt...",
      "tickers": ["EXMPL"],
      "content_hash": "..."
    }

    The timestamp fields are especially important. If an article appeared after the market closed, a model predicting that same day’s closing price must not be allowed to use it.


    Why News Data Is Useful in Stock-Market Prediction

    Price and volume data describe market behaviour numerically. News adds information about events that may change future cash flows, risk or investor expectations.

    Suppose a company’s share price falls by 8%. Historical price data can show:

    • The size of the fall
    • Trading volume
    • Recent volatility
    • Whether the price crossed a moving average

    News data may reveal that the company:

    • Missed its earnings forecast
    • Lost a major customer
    • Announced a product recall
    • Received a regulatory penalty

    An AI model can convert news text into features such as:

    • Positive, neutral or negative sentiment
    • Sentiment confidence
    • Number of relevant articles
    • Source diversity
    • Novelty compared with earlier stories
    • Event category
    • Company or sector mentioned
    • Time since publication
    • Unexpectedness or severity

    News does not automatically improve a prediction model. The benefit depends on the quality of the collection pipeline, the target being predicted and whether the information was genuinely available before the prediction time.


    News APIs, RSS Feeds and HTML Scraping Compared

    There are several ways to obtain news. They are not interchangeable.

    MethodAdvantagesLimitationsBest use
    Licensed financial feedConsistent schema, strong coverage, clear usage rightsOften expensiveProduction research and trading systems
    News APIStructured JSON, easy queries, fewer broken selectorsQuotas, licensing limits, possible text truncationPrototypes and article discovery
    Publisher RSS feedLightweight, timestamped, easy to parseOften contains only summariesDiscovery and monitoring
    Permitted HTML scrapingCan extract page metadata and available textFragile, source-specific and legally sensitiveResearch where permitted
    Search-engine resultsBroad discoveryUnstable, duplicate-heavy and often restrictedManual research, not a primary dataset

    NewsAPI’s Everything endpoint, for example, is designed for article discovery and analysis using keyword, date, domain and language filters. GDELT also provides large-scale news datasets and live APIs. These options may be more suitable than maintaining many site-specific scrapers.

    An API does not remove every problem. You must still check:

    • Whether historical results are complete
    • Whether full text or only a snippet is supplied
    • How publication times are defined
    • Whether the licence permits storage, modelling and redistribution
    • Whether the provider corrects or deletes records later
    • Whether the source list changes over time

    For a learning project, RSS discovery plus conservative webpage extraction demonstrates the complete acquisition process without pretending that every news website can or should be crawled.


    Legal, Ethical and Technical Rules

    Before writing a scraper, create a source policy.

    1. Read the Website’s Terms

    Check the site’s terms of service, data policy and licensing information. A publicly viewable page is not automatically free to copy, store indefinitely or republish.

    Do not bypass:

    • Paywalls
    • Authentication
    • CAPTCHA challenges
    • Access controls
    • Rate limits
    • Technical restrictions

    If the website offers an API or licensed feed, use it when practical.

    2. Check robots.txt

    The Robots Exclusion Protocol is standardised in RFC 9309. A compliant crawler retrieves the site’s /robots.txt file and checks whether its user agent is permitted to request a particular URL.

    robots.txt is not a complete legal permission system. An allowed path does not cancel copyright, contract or privacy obligations. A disallowed path should nevertheless be treated as a clear instruction not to crawl it.

    3. Identify the Crawler

    Use a descriptive user agent rather than pretending to be a normal browser:

    StockNewsResearchBot/1.0 (+https://your-domain.example/bot; [email protected])

    The linked page can explain:

    • Who operates the crawler
    • Its research purpose
    • Its normal request frequency
    • How a publisher can contact you
    • How to request removal

    4. Crawl Slowly

    A research crawler does not need to request the same server several times per second.

    Use:

    • A delay between requests
    • Connection and read timeouts
    • A limited retry count
    • Exponential backoff for temporary failures
    • Per-domain request scheduling
    • Caching to avoid downloading unchanged pages

    Respect HTTP 429 Too Many Requests and Retry-After responses.

    5. Store Only What You Need

    For many projects, a headline, permitted excerpt, source, URL and timestamp are enough. Storing and redistributing complete copyrighted articles can create additional legal and licensing risks.

    Keep the original source URL so authorised users can return to the publisher.


    Designing the Dataset Before Writing the Scraper

    A crawler should write records into a predefined schema. Otherwise, every source produces a different collection of columns.

    Use at least these fields:

    FieldPurpose
    sourcePublisher or feed name
    urlURL discovered by the system
    canonical_urlPublisher’s preferred URL when available
    titleArticle headline
    descriptionSummary or metadata description
    authorAuthor when supplied
    published_atClaimed original publication time
    modified_atLater modification time, if supplied
    collected_atTime your system retrieved the page
    textPermitted body text or excerpt
    languageArticle language when known
    tickersCompanies matched by controlled rules
    content_hashHash used for deduplication
    statusSuccess, blocked, missing date or other state

    Do not overwrite published_at with the collection time. They answer different questions.

    Also consider preserving the raw response metadata separately:

    • HTTP status
    • ETag
    • Last-Modified
    • Content type
    • Retrieval duration
    • Extractor version
    • Feed URL

    This information helps diagnose coverage gaps and reproduce the dataset later.


    Project Structure

    Create a folder with the following files:

    stock-news-scraper/
    ├── scrape_news.py
    ├── requirements.txt
    ├── news_articles.csv
    └── news_articles.jsonl

    The output files are created automatically after the scraper runs.


    Step 1: Install Python Packages

    Create a virtual environment:

    python -m venv .venv

    Activate it on Linux or macOS:

    source .venv/bin/activate

    On Windows PowerShell:

    .venv\Scripts\Activate.ps1

    Create requirements.txt:

    beautifulsoup4
    feedparser
    python-dateutil
    requests

    Install the dependencies:

    python -m pip install -r requirements.txt

    Pin tested package versions before deploying a production pipeline. A lock file or fully pinned requirements file helps keep future runs reproducible.


    Step 2: Configure Sources and Company Terms

    The complete script below intentionally uses placeholder feeds. Replace them only with sources you are authorised to access.

    RSS_SOURCES = [
        {
            "name": "Example Financial News",
            "feed_url": "https://news.example.com/finance/rss.xml",
        },
        {
            "name": "Example Exchange Announcements",
            "feed_url": "https://exchange.example.com/announcements.xml",
        },
    ]
    
    COMPANIES = {
        "AAPL": ["Apple", "Apple Inc."],
        "MSFT": ["Microsoft", "Microsoft Corp."],
        "NVDA": ["Nvidia", "NVIDIA Corporation"],
    }

    Avoid using a ticker alone for ambiguous symbols. A short ticker such as CAT, IT or ON can occur frequently in normal English.

    A stronger company dictionary includes:

    • Official company name
    • Common short name
    • Unambiguous ticker forms such as $AAPL
    • Former names
    • Important subsidiaries
    • Exchange name
    • Country or industry context

    Entity linking should be treated as its own data-quality problem, not as a simple substring search.


    Step 3: Complete News-Scraping Script

    Save the following as scrape_news.py:

    from __future__ import annotations
    
    import csv
    import hashlib
    import json
    import re
    import time
    from dataclasses import asdict, dataclass
    from datetime import datetime, timezone
    from pathlib import Path
    from typing import Any
    from urllib.parse import parse_qsl, urlencode, urljoin, urlsplit, urlunsplit
    from urllib.robotparser import RobotFileParser
    
    import feedparser
    import requests
    from bs4 import BeautifulSoup
    from dateutil import parser as date_parser
    from requests.adapters import HTTPAdapter
    from urllib3.util.retry import Retry
    
    
    USER_AGENT = (
        "StockNewsResearchBot/1.0 "
        "(+https://your-domain.example/bot; [email protected])"
    )
    
    REQUEST_DELAY_SECONDS = 2.0
    CONNECT_TIMEOUT_SECONDS = 5
    READ_TIMEOUT_SECONDS = 20
    MAX_ARTICLES_PER_FEED = 20
    
    CSV_PATH = Path("news_articles.csv")
    JSONL_PATH = Path("news_articles.jsonl")
    
    RSS_SOURCES = [
        {
            "name": "Example Financial News",
            "feed_url": "https://news.example.com/finance/rss.xml",
        },
    ]
    
    COMPANIES = {
        "AAPL": ["Apple", "Apple Inc."],
        "MSFT": ["Microsoft", "Microsoft Corp."],
        "NVDA": ["Nvidia", "NVIDIA Corporation"],
    }
    
    TRACKING_PARAMETERS = {
        "fbclid",
        "gclid",
        "mc_cid",
        "mc_eid",
        "ref",
        "source",
    }
    
    
    @dataclass
    class ArticleRecord:
        source: str
        url: str
        canonical_url: str
        title: str
        description: str
        author: str
        published_at: str
        modified_at: str
        collected_at: str
        text: str
        language: str
        tickers: list[str]
        content_hash: str
        status: str
    
    
    def create_session() -> requests.Session:
        retry = Retry(
            total=3,
            connect=3,
            read=2,
            status=3,
            backoff_factor=1.0,
            status_forcelist=(429, 500, 502, 503, 504),
            allowed_methods=frozenset({"GET"}),
            respect_retry_after_header=True,
        )
    
        adapter = HTTPAdapter(max_retries=retry)
        session = requests.Session()
        session.headers.update(
            {
                "User-Agent": USER_AGENT,
                "Accept": (
                    "text/html,application/xhtml+xml,application/rss+xml,"
                    "application/xml;q=0.9,*/*;q=0.8"
                ),
            }
        )
        session.mount("https://", adapter)
        session.mount("http://", adapter)
        return session
    
    
    def utc_now_iso() -> str:
        return datetime.now(timezone.utc).isoformat()
    
    
    def normalise_space(value: str) -> str:
        return re.sub(r"\s+", " ", value).strip()
    
    
    def normalise_url(url: str) -> str:
        parts = urlsplit(url)
    
        filtered_query = []
        for key, value in parse_qsl(parts.query, keep_blank_values=True):
            lower_key = key.lower()
            if lower_key.startswith("utm_"):
                continue
            if lower_key in TRACKING_PARAMETERS:
                continue
            filtered_query.append((key, value))
    
        path = parts.path or "/"
        if path != "/":
            path = path.rstrip("/")
    
        return urlunsplit(
            (
                parts.scheme.lower(),
                parts.netloc.lower(),
                path,
                urlencode(filtered_query, doseq=True),
                "",
            )
        )
    
    
    def parse_datetime(value: Any) -> str:
        if not value:
            return ""
    
        try:
            parsed = date_parser.parse(str(value))
        except (TypeError, ValueError, OverflowError):
            return ""
    
        if parsed.tzinfo is None:
            return ""
    
        return parsed.astimezone(timezone.utc).isoformat()
    
    
    def matches_tickers(text: str) -> list[str]:
        matched = []
    
        for ticker, aliases in COMPANIES.items():
            patterns = [rf"\${re.escape(ticker)}\b"]
            patterns.extend(
                rf"\b{re.escape(alias)}\b"
                for alias in aliases
            )
    
            if any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns):
                matched.append(ticker)
    
        return sorted(matched)
    
    
    def get_robot_parser(
        session: requests.Session,
        url: str,
        cache: dict[str, RobotFileParser | None],
    ) -> RobotFileParser | None:
        parts = urlsplit(url)
        origin = f"{parts.scheme}://{parts.netloc}"
    
        if origin in cache:
            return cache[origin]
    
        robots_url = urljoin(origin, "/robots.txt")
        parser = RobotFileParser()
        parser.set_url(robots_url)
    
        try:
            response = session.get(
                robots_url,
                timeout=(CONNECT_TIMEOUT_SECONDS, READ_TIMEOUT_SECONDS),
            )
            response.raise_for_status()
            parser.parse(response.text.splitlines())
            cache[origin] = parser
        except requests.RequestException as error:
            print(f"Could not verify robots.txt for {origin}: {error}")
            cache[origin] = None
    
        return cache[origin]
    
    
    def is_allowed(
        session: requests.Session,
        url: str,
        cache: dict[str, RobotFileParser | None],
    ) -> bool:
        parser = get_robot_parser(session, url, cache)
    
        # Conservative policy: skip when robots.txt cannot be verified.
        if parser is None:
            return False
    
        return parser.can_fetch(USER_AGENT, url)
    
    
    def iter_json_ld(soup: BeautifulSoup):
        for script in soup.select('script[type="application/ld+json"]'):
            raw = script.string or script.get_text()
            if not raw.strip():
                continue
    
            try:
                value = json.loads(raw)
            except json.JSONDecodeError:
                continue
    
            values = value if isinstance(value, list) else [value]
    
            for item in values:
                if not isinstance(item, dict):
                    continue
    
                graph = item.get("@graph")
                if isinstance(graph, list):
                    values.extend(
                        node for node in graph if isinstance(node, dict)
                    )
    
                yield item
    
    
    def find_news_json_ld(soup: BeautifulSoup) -> dict[str, Any]:
        accepted_types = {
            "Article",
            "NewsArticle",
            "ReportageNewsArticle",
        }
    
        for item in iter_json_ld(soup):
            item_type = item.get("@type", "")
            types = item_type if isinstance(item_type, list) else [item_type]
    
            if any(value in accepted_types for value in types):
                return item
    
        return {}
    
    
    def meta_content(soup: BeautifulSoup, *selectors: str) -> str:
        for selector in selectors:
            element = soup.select_one(selector)
            if element and element.get("content"):
                return normalise_space(str(element["content"]))
        return ""
    
    
    def author_from_json_ld(value: Any) -> str:
        if isinstance(value, dict):
            return normalise_space(str(value.get("name", "")))
    
        if isinstance(value, list):
            names = [author_from_json_ld(item) for item in value]
            return ", ".join(name for name in names if name)
    
        if isinstance(value, str):
            return normalise_space(value)
    
        return ""
    
    
    def extract_article_text(soup: BeautifulSoup, json_ld: dict[str, Any]) -> str:
        body = json_ld.get("articleBody")
        if isinstance(body, str) and body.strip():
            return normalise_space(body)
    
        # Generic fallbacks work on some pages, but source-specific selectors
        # are normally more accurate. Add them only for approved sources.
        candidates = soup.select(
            "article p, main article p, [itemprop='articleBody'] p"
        )
    
        paragraphs = []
        for paragraph in candidates:
            text = normalise_space(paragraph.get_text(" ", strip=True))
            if len(text) >= 40:
                paragraphs.append(text)
    
        return "\n\n".join(paragraphs)
    
    
    def make_hash(title: str, text: str) -> str:
        material = normalise_space(f"{title}\n{text}").lower()
        return hashlib.sha256(material.encode("utf-8")).hexdigest()
    
    
    def extract_article(
        session: requests.Session,
        source_name: str,
        url: str,
        feed_title: str,
        feed_summary: str,
        feed_published: str,
    ) -> ArticleRecord:
        response = session.get(
            url,
            timeout=(CONNECT_TIMEOUT_SECONDS, READ_TIMEOUT_SECONDS),
        )
        response.raise_for_status()
    
        content_type = response.headers.get("Content-Type", "").lower()
        if "text/html" not in content_type:
            raise ValueError(f"Unsupported content type: {content_type}")
    
        soup = BeautifulSoup(response.text, "html.parser")
        json_ld = find_news_json_ld(soup)
    
        title = normalise_space(
            str(json_ld.get("headline", ""))
            or meta_content(soup, 'meta[property="og:title"]')
            or feed_title
        )
    
        description = normalise_space(
            str(json_ld.get("description", ""))
            or meta_content(
                soup,
                'meta[property="og:description"]',
                'meta[name="description"]',
            )
            or feed_summary
        )
    
        canonical_element = soup.select_one('link[rel="canonical"]')
        canonical_url = url
        if canonical_element and canonical_element.get("href"):
            canonical_url = urljoin(url, str(canonical_element["href"]))
    
        canonical_url = normalise_url(canonical_url)
        text = extract_article_text(soup, json_ld)
        combined_text = f"{title}\n{description}\n{text}"
    
        published_at = parse_datetime(
            json_ld.get("datePublished")
            or meta_content(
                soup,
                'meta[property="article:published_time"]',
            )
            or feed_published
        )
    
        modified_at = parse_datetime(
            json_ld.get("dateModified")
            or meta_content(
                soup,
                'meta[property="article:modified_time"]',
            )
        )
    
        language = str(json_ld.get("inLanguage", ""))
        if not language and soup.html:
            language = str(soup.html.get("lang", ""))
    
        return ArticleRecord(
            source=source_name,
            url=normalise_url(url),
            canonical_url=canonical_url,
            title=title,
            description=description,
            author=author_from_json_ld(json_ld.get("author")),
            published_at=published_at,
            modified_at=modified_at,
            collected_at=utc_now_iso(),
            text=text,
            language=language,
            tickers=matches_tickers(combined_text),
            content_hash=make_hash(title, text),
            status="ok" if published_at else "missing_published_time",
        )
    
    
    def load_existing_keys() -> tuple[set[str], set[str]]:
        urls: set[str] = set()
        hashes: set[str] = set()
    
        if not JSONL_PATH.exists():
            return urls, hashes
    
        with JSONL_PATH.open("r", encoding="utf-8") as file:
            for line in file:
                try:
                    item = json.loads(line)
                except json.JSONDecodeError:
                    continue
    
                if item.get("canonical_url"):
                    urls.add(item["canonical_url"])
                if item.get("content_hash"):
                    hashes.add(item["content_hash"])
    
        return urls, hashes
    
    
    def save_records(records: list[ArticleRecord]) -> None:
        if not records:
            return
    
        with JSONL_PATH.open("a", encoding="utf-8") as file:
            for record in records:
                file.write(json.dumps(asdict(record), ensure_ascii=False) + "\n")
    
        fieldnames = list(asdict(records[0]).keys())
        write_header = not CSV_PATH.exists()
    
        with CSV_PATH.open("a", encoding="utf-8", newline="") as file:
            writer = csv.DictWriter(file, fieldnames=fieldnames)
            if write_header:
                writer.writeheader()
    
            for record in records:
                item = asdict(record)
                item["tickers"] = json.dumps(item["tickers"])
                writer.writerow(item)
    
    
    def run() -> None:
        session = create_session()
        robot_cache: dict[str, RobotFileParser | None] = {}
        seen_urls, seen_hashes = load_existing_keys()
        new_records: list[ArticleRecord] = []
    
        for source in RSS_SOURCES:
            feed_url = source["feed_url"]
            source_name = source["name"]
    
            try:
                feed_response = session.get(
                    feed_url,
                    timeout=(CONNECT_TIMEOUT_SECONDS, READ_TIMEOUT_SECONDS),
                )
                feed_response.raise_for_status()
            except requests.RequestException as error:
                print(f"Feed failed for {source_name}: {error}")
                continue
    
            feed = feedparser.parse(feed_response.content)
    
            for entry in feed.entries[:MAX_ARTICLES_PER_FEED]:
                url = normalise_url(str(entry.get("link", "")))
                if not url or url in seen_urls:
                    continue
    
                feed_title = normalise_space(str(entry.get("title", "")))
                feed_summary = normalise_space(
                    BeautifulSoup(
                        str(entry.get("summary", "")),
                        "html.parser",
                    ).get_text(" ", strip=True)
                )
    
                candidate_text = f"{feed_title}\n{feed_summary}"
                if not matches_tickers(candidate_text):
                    continue
    
                if not is_allowed(session, url, robot_cache):
                    print(f"Skipped by crawler policy: {url}")
                    continue
    
                time.sleep(REQUEST_DELAY_SECONDS)
    
                try:
                    record = extract_article(
                        session=session,
                        source_name=source_name,
                        url=url,
                        feed_title=feed_title,
                        feed_summary=feed_summary,
                        feed_published=str(
                            entry.get("published", "")
                            or entry.get("updated", "")
                        ),
                    )
                except (requests.RequestException, ValueError) as error:
                    print(f"Article failed: {url}: {error}")
                    continue
    
                if record.canonical_url in seen_urls:
                    continue
                if record.content_hash in seen_hashes:
                    continue
    
                seen_urls.add(record.canonical_url)
                seen_hashes.add(record.content_hash)
                new_records.append(record)
                print(f"Collected: {record.title}")
    
        save_records(new_records)
        print(f"Saved {len(new_records)} new article(s).")
    
    
    if __name__ == "__main__":
        run()

    Before running the script, change:

    • The contact details in USER_AGENT
    • The placeholder RSS feed
    • The company and ticker dictionary
    • The request delay if a source asks for a slower rate
    • The extraction policy if full article text is not licensed for your use

    Run it with:

    python scrape_news.py

    How the Scraper Works

    1. RSS Is Used for Discovery

    The script does not crawl a website looking for every possible link. It reads an approved RSS feed and processes recent entries.

    This reduces unnecessary requests and provides useful metadata such as:

    • Headline
    • Article URL
    • Summary
    • Publication or update time

    The RSS result is still verified against the article page because feeds may contain abbreviated or differently formatted metadata.

    2. Obvious Irrelevant Articles Are Filtered Early

    The feed title and summary are checked against the controlled company dictionary before the article page is requested.

    This saves bandwidth, but it can create false negatives. An article may discuss a company indirectly without naming it in the headline or summary.

    For better recall, a production system might first collect all permitted feed metadata and perform entity recognition later.

    3. robots.txt Is Checked Per Origin

    The script uses Python’s RobotFileParser and caches the result for each origin. It adopts a conservative policy: if robots.txt cannot be verified, the page is skipped.

    Different projects may use a different failure policy, but it should be documented and applied consistently.

    4. Structured Metadata Is Preferred

    Many publishers include Schema.org NewsArticle or Article data in a JSON-LD script. This may provide:

    • headline
    • description
    • datePublished
    • dateModified
    • author
    • articleBody
    • inLanguage

    Structured metadata is usually more stable than selecting CSS classes designed only for page layout.

    The script then falls back to Open Graph metadata, normal meta descriptions and RSS values.

    5. Generic Paragraph Extraction Is a Fallback

    The selector:

    article p, main article p, [itemprop='articleBody'] p

    works on some pages but will not work perfectly everywhere. It may include captions, related-story text or subscription messages.

    For an authorised source, add a tested source-specific extractor instead of building a single enormous selector for every website.

    6. URLs and Content Are Deduplicated

    The script removes common tracking parameters and fragments from URLs. It also creates a SHA-256 hash from the normalised title and body.

    This catches:

    • The same URL discovered twice
    • Tracking variants of one URL
    • Identical content published under different URLs

    It does not catch heavily edited or syndicated versions of the same story. Near-duplicate detection is discussed later.


    Using a News API Instead

    If your use case and licence are supported, a news API can replace the discovery and extraction sections.

    Here is a simple NewsAPI example:

    import os
    from datetime import date, timedelta
    
    import requests
    
    
    api_key = os.environ["NEWS_API_KEY"]
    start_date = date.today() - timedelta(days=7)
    
    response = requests.get(
        "https://newsapi.org/v2/everything",
        headers={"X-Api-Key": api_key},
        params={
            "q": 'Apple OR "Apple Inc" OR AAPL',
            "from": start_date.isoformat(),
            "language": "en",
            "sortBy": "publishedAt",
            "pageSize": 100,
        },
        timeout=(5, 20),
    )
    
    response.raise_for_status()
    payload = response.json()
    
    for article in payload.get("articles", []):
        print(article["publishedAt"], article["title"])

    Set the key outside the source code:

    export NEWS_API_KEY="your-key-here"

    Never commit an API key to Git.

    The API response still requires:

    • Pagination
    • Date-window management
    • Deduplication
    • Query logging
    • Rate-limit handling
    • Licence compliance
    • Ticker or company mapping
    • Timestamp validation
    • Coverage monitoring

    Do not assume an API’s content field contains the complete article. Check the provider’s current documentation and plan.


    Improving Deduplication

    Financial news is frequently syndicated. Ten URLs may represent one original report rather than ten independent signals.

    Basic deduplication should compare:

    1. Normalised canonical URL
    2. Exact content hash
    3. Normalised headline
    4. Publication time
    5. Source and author

    For near duplicates, calculate similarity between titles or article embeddings.

    Example using title tokens:

    import re
    
    
    def title_tokens(title):
        return set(re.findall(r"[a-z0-9]+", title.lower()))
    
    
    def jaccard_similarity(first_title, second_title):
        first = title_tokens(first_title)
        second = title_tokens(second_title)
    
        if not first and not second:
            return 1.0
        if not first or not second:
            return 0.0
    
        return len(first & second) / len(first | second)

    A high similarity score is a review signal, not proof that two stories are identical.

    Keep both concepts:

    • article_id: one collected page
    • story_cluster_id: several pages describing the same underlying event

    This allows the model to distinguish genuine source confirmation from simple republication.


    The Most Important Finance Problem: Point-in-Time Correctness

    Look-ahead bias occurs when the training dataset gives a model information that would not have been available at prediction time.

    Imagine this sequence:

    TimeEvent
    Monday 4:00 PMMarket closes
    Monday 4:15 PMCompany releases weak results
    Monday 4:17 PMNews article is published
    Tuesday 9:30 AMMarket reopens and price falls

    If the Monday after-hours article is assigned to Monday’s closing-price prediction, the dataset leaks future information.

    For every record, preserve:

    • published_at: time claimed by the publisher
    • modified_at: later edit time
    • collected_at: time observed by your collector
    • available_at: earliest defensible time the model could have received it

    A conservative definition is:

    available_at = max(published_at, first_observed_at)

    This prevents a crawler that runs on Tuesday from pretending it definitely possessed the article on Monday merely because the page now displays a Monday timestamp.

    Aligning News with Trading Sessions

    Do not group articles by calendar date alone.

    The correct trading session depends on:

    • Exchange timezone
    • Market opening and closing time
    • Weekends
    • Public holidays
    • Half-day trading sessions
    • Pre-market and after-hours trading
    • The model’s exact prediction cutoff

    For a model that makes one prediction immediately before the regular market opens:

    • News before the cutoff may belong to that session.
    • News after the cutoff belongs to the next prediction window.

    For a model predicting five-minute returns, publication timestamps may not be precise enough. You may need first-observed times and a realistic delay for acquisition, processing and model inference.


    Cleaning the Text Without Destroying Evidence

    Keep two layers:

    Raw Layer

    The raw layer should be append-only where practical. It contains the exact permitted data returned by the collector plus retrieval metadata.

    Do not silently rewrite old raw records when cleaning rules change.

    Processed Layer

    The processed layer can contain:

    • Lowercased text when appropriate
    • Removed navigation fragments
    • Normalised whitespace
    • Sentence segmentation
    • Language detection
    • Company entities
    • Sentiment score
    • Event classification
    • Embeddings
    • Story clusters
    • Trading-session assignment

    Version the transformation code. A dataset should record which extractor, entity matcher and sentiment model produced each feature.

    Avoid aggressive cleaning before using a transformer model. Punctuation, numbers, negation and casing can carry useful meaning.

    For example:

    Profit increased to $2.1 billion.

    should not become:

    profit increased billion

    The removed figure may be the most important part of the sentence.


    Converting News into Model Features

    After collection, aggregate article-level information into features for a defined time window.

    Possible daily features include:

    FeatureMeaning
    article_countNumber of relevant articles available before cutoff
    unique_story_countNumber of deduplicated event clusters
    mean_sentimentAverage sentiment score
    min_sentimentMost negative article score
    max_sentimentMost positive article score
    negative_article_ratioShare of articles classified as negative
    source_countNumber of distinct publishers
    news_volume_zscoreAbnormal news volume relative to history
    hours_since_latest_newsRecency of the latest relevant article
    earnings_news_countNumber of earnings-related stories
    regulatory_news_countNumber of regulatory stories

    Do not calculate a simple average before deduplicating. Twenty copied versions of one negative report should not automatically count as twenty independent negative events.

    A weighted sentiment feature could be:

    weighted sentiment = sum(sentiment × relevance × recency × source weight)
                         / sum(relevance × recency × source weight)

    Every weighting rule should be learned or justified using training and validation data. Do not tune the rule on the final test period.


    Matching Articles to the Correct Company

    Company matching is harder than it looks.

    Consider the word Apple. It may refer to:

    • Apple Inc.
    • The fruit
    • Apple Records
    • A different organisation with Apple in its name

    A good entity-linking pipeline uses several signals:

    • Full company name
    • Ticker with exchange context
    • Executives and products
    • Industry terms
    • Country
    • Other companies mentioned
    • Publisher section
    • Named-entity recognition

    Create a relevance score rather than accepting every keyword match.

    Example rule:

    +3  full company name in headline
    +2  recognised product and company in body
    +2  ticker written as $AAPL
    +1  company name repeated in first paragraph
    -3  ambiguous word used without business context

    Then manually review samples near the threshold. False company assignments can corrupt sentiment features even when the scraper itself works perfectly.


    Common Web-Scraping Problems

    1. JavaScript-Rendered Pages

    Some HTML responses contain very little article content because JavaScript loads the page after the browser starts.

    Before introducing browser automation, look for:

    • An official API
    • RSS content
    • JSON-LD metadata
    • Server-rendered article HTML
    • A licensed feed

    Browser automation is slower and more resource-intensive. It also does not grant permission to access content.

    2. Selector Changes

    A class such as:

    .article-body-v4-final

    may disappear during a redesign.

    Prefer structured data and semantic elements, and maintain source-specific tests.

    3. Missing or Ambiguous Dates

    A page may show:

    • Only a local time without timezone
    • Only “two hours ago”
    • A modified time instead of original publication time
    • A date with no time

    Do not invent precision. Flag the record for review or exclude it from time-sensitive experiments.

    4. Corrected Articles

    A publisher may change a headline or correct figures after publication.

    Store version observations rather than replacing the earlier record without a trace. The model should see only the version that existed at the relevant time.

    5. Paywalls and Login Pages

    A successful HTTP status does not prove that article content was retrieved. The scraper may have captured a subscription prompt.

    Add quality checks such as:

    • Minimum text length
    • Required headline
    • Valid publication time
    • Known boilerplate rejection
    • Article-to-navigation text ratio

    Never attempt to defeat a paywall or access control.

    6. Soft Blocks

    A server may return HTTP 200 with a block or challenge page.

    Detect unexpected titles, content types and page templates. Stop requesting the source and investigate rather than increasing the request rate or disguising the crawler.


    Testing the Acquisition Pipeline

    A scraper is a data pipeline and needs tests.

    Unit Tests

    Test small functions using saved, authorised HTML fixtures:

    • URL normalisation
    • Date parsing
    • Company matching
    • JSON-LD extraction
    • Content hashing
    • Near-duplicate scoring

    Source Contract Tests

    For each approved source, periodically verify that:

    • RSS is reachable
    • Article links are valid
    • The title is extracted
    • The publication time includes a timezone
    • Text length remains within an expected range
    • The extractor does not collect navigation or legal notices as article text

    Data-Quality Checks

    Run checks after every batch:

    assert all(record.url for record in records)
    assert all(record.title for record in records)
    assert all(record.collected_at for record in records)
    assert len({record.content_hash for record in records}) == len(records)

    Also report:

    • Articles discovered per source
    • Articles collected per source
    • Blocked or disallowed URLs
    • Missing publication timestamps
    • Extraction failures
    • Duplicate rate
    • Empty-text rate
    • Articles per company
    • Delay from publication to collection

    A sudden drop from 500 articles per day to 10 is probably a pipeline problem, not a quiet financial-news day.


    Scheduling the Scraper

    For a Linux learning environment, a cron entry can run the script every 30 minutes:

    */30 * * * * cd /path/to/stock-news-scraper && /path/to/.venv/bin/python scrape_news.py >> scraper.log 2>&1

    Production systems should add:

    • A scheduler lock to prevent overlapping runs
    • Centralised logs
    • Failure alerts
    • Per-source backoff
    • Idempotent writes
    • Database transactions
    • Secrets management
    • Data-retention rules
    • Source-level kill switches

    Avoid running a frequent schedule merely because it is possible. Match the interval to the authorised access rate and the prediction horizon.

    A daily model does not normally require scraping the same source every minute.


    Recommended Production Architecture

    A larger pipeline can separate the following stages:

    1. Discovery — receives article URLs from APIs, feeds or approved indexes.
    2. Policy check — applies source permissions, robots rules and request limits.
    3. Fetcher — retrieves permitted content and records HTTP metadata.
    4. Extractor — converts source documents into a common schema.
    5. Raw storage — preserves authorised original observations.
    6. Deduplicator — assigns canonical articles and story clusters.
    7. Entity linker — maps stories to companies, sectors and instruments.
    8. NLP processor — creates sentiment, event and embedding features.
    9. Point-in-time joiner — aligns features with valid market timestamps.
    10. Quality monitor — detects missing coverage and extraction drift.

    Each stage should be repeatable and versioned. This makes it possible to improve sentiment analysis without recrawling every source or losing the original observation time.


    Mistakes to Avoid

    Scraping Everything First

    Collecting millions of pages before defining the prediction target creates a large but poorly aligned dataset.

    Start with:

    • A small list of companies
    • A defined prediction horizon
    • A limited date range
    • A few authorised sources
    • A clear timestamp policy

    Ignoring Source Changes

    If the list of publishers changes over time, a model may learn differences in data coverage rather than genuine market behaviour.

    Record source availability and collection failures.

    Treating Every Mention as Relevant

    An article that mentions a company in a related-links section should not necessarily affect its sentiment score.

    Using the Current Page for Historical Backtests

    The page viewed today may contain a corrected headline, updated text or changed publication label. It is not automatically a faithful copy of what existed on the historical date.

    Randomly Splitting News Records

    Near-duplicate stories can enter both the training and test sets. Split chronologically and, where possible, keep a complete story cluster in one split.

    Evaluating Only Prediction Accuracy

    A model can achieve a reasonable direction accuracy while still producing an unprofitable strategy after spreads, slippage, fees and delayed news processing.


    Final Acquisition Checklist

    Before using news in a stock-prediction model, confirm that:

    • Each source has an approved access method.
    • Terms and licences have been reviewed.
    • robots.txt is checked for HTML crawling.
    • The crawler identifies itself honestly.
    • Requests use timeouts, retries and conservative rate limits.
    • Paywalls and access controls are not bypassed.
    • Raw and processed data are stored separately.
    • Original, modified, collected and available times are distinguished.
    • All timestamps are converted to a consistent timezone.
    • Missing or ambiguous timestamps are flagged.
    • Canonical URLs and tracking parameters are handled.
    • Exact and near-duplicate stories are identified.
    • Articles are linked to companies using more than ambiguous ticker strings.
    • Collection gaps are monitored by source and date.
    • News is aligned with the correct trading session and cutoff.
    • Train, validation and test periods are separated chronologically.
    • The data licence permits the intended storage, modelling and sharing.

    Frequently Asked Questions

    Is web scraping legal?

    There is no single worldwide answer. It depends on the source, data, access method, contract, copyright, privacy rules and jurisdiction. robots.txt is important crawler guidance, but it is not a complete legal permission system. Obtain professional advice for commercial or high-risk use.

    Should I scrape financial-news websites or use an API?

    Use an authorised API or licensed feed when it provides the coverage you need. It normally produces more stable structured data and reduces scraper maintenance. HTML extraction is useful for permitted research sources that lack a suitable structured interface.

    Can Beautiful Soup scrape JavaScript-rendered pages?

    Beautiful Soup parses the HTML it receives; it does not run webpage JavaScript. Check for an official API, RSS feed, JSON-LD or server-rendered content before considering browser automation.

    How often should a news scraper run?

    The frequency should match the prediction horizon, source rules and permitted request rate. A daily model may need only hourly or daily collection. Intraday research requires more careful point-in-time timestamps and latency measurement.

    Should I save the complete article text?

    Only if your licence and use case permit it. For some projects, storing the headline, authorised summary, metadata, derived features and source URL is more appropriate.

    What is the difference between published_at and collected_at?

    published_at is the publication time claimed by the source. collected_at is when your system retrieved the record. Both are required to evaluate whether the information was truly available at prediction time.

    Why are there duplicate financial-news stories?

    Publishers syndicate wire reports, quote one another, update developing stories and create multiple URLs with tracking parameters. Deduplication should use canonical URLs, hashes and similarity or event clustering.

    Can sentiment analysis predict stock prices?

    Sentiment may be a useful feature, but it cannot guarantee accurate or profitable forecasts. The market reaction depends on expectations, surprise, source credibility, timing, liquidity and whether the information was already priced in.

    Which sentiment model should I use?

    Compare a finance-specific language model with simple baselines. Validate on later unseen data. A more complicated model is not automatically better, especially when labels are weak or the news-to-company mapping is inaccurate.

    Can I use headlines without article bodies?

    Yes. Headlines are cheaper to store and often contain the main event, but they can omit qualifications and context. Test headline-only, summary-only and authorised full-text features separately.

    How do I avoid look-ahead bias?

    Use the earliest defensible availability time, apply the correct exchange timezone and prediction cutoff, and split data chronologically. Do not use later edits or today’s version of a page as if it were the historical version.

    What should I do when robots.txt cannot be retrieved?

    Choose and document a conservative policy. The tutorial skips crawling when it cannot verify the rules. For a production system, pause that source and review the failure rather than repeatedly requesting pages.

    What comes after data acquisition?

    The next stages are text cleaning, entity linking, duplicate clustering, sentiment or event extraction, point-in-time aggregation and chronological model evaluation.

    Is this financial advice?

    No. This tutorial demonstrates data engineering and machine-learning concepts. It does not recommend buying, selling or holding any investment.


    Final Result

    We have designed a news-acquisition pipeline for a stock-market prediction project.

    The completed approach:

    1. Prioritises licensed feeds, APIs and RSS over unnecessary HTML crawling.
    2. Checks source rules and robots.txt before fetching permitted pages.
    3. Uses an identifiable user agent, timeouts, throttling and limited retries.
    4. Extracts structured article metadata before falling back to generic HTML selectors.
    5. Stores source, URL, publication time, modification time and collection time.
    6. Matches articles to a controlled list of companies.
    7. Removes tracking URLs and exact content duplicates.
    8. Saves machine-readable JSONL and CSV outputs.
    9. Preserves the information needed for point-in-time backtesting.
    10. Prepares the records for entity linking, sentiment analysis and event classification.

    The most important lesson is that news volume alone does not make a good dataset. A smaller collection with reliable source permissions, accurate timestamps, strong company matching and correct trading-session alignment is more valuable than a huge archive full of duplicates and future information.

    The next stage is to transform the collected news into sentiment and event features, join those features to historical market data without leakage and compare the resulting model against price-only baselines.

  • Pokémon UNITE: Catching ’Em All in a New Way — Review and Beginner Guide

    Pokémon UNITE takes a familiar idea—choosing, training and evolving Pokémon—and places it inside a fast team strategy game.

    It is not a traditional Pokémon adventure. There is no large world to explore, no Pokédex to complete and no turn-based battle system. Instead, two teams select Pokémon and compete to score the most points before the timer ends.

    Each standard match is short enough to finish in about ten minutes, but it still includes many of the things Pokémon fans recognise: wild Pokémon, evolution, signature moves, held items and powerful Legendary Pokémon.

    This review and guide explains what Pokémon UNITE is, how its battles work, whether it is still worth playing, how the free-to-start system affects progress and what beginners should do during their first matches.


    Quick Answer

    Pokémon UNITE is a free-to-start 5-on-5 multiplayer online battle arena game for Nintendo Switch and compatible mobile devices.

    Players defeat wild Pokémon, gain levels, choose new moves, collect Aeos energy and deposit that energy into the opposing team’s goal zones. The team with the most points when time expires wins.

    Pokémon UNITE Review Score

    CategoryScore
    Core gameplay8.5/10
    Accessibility for beginners8/10
    Pokémon presentation8.5/10
    Teamwork and strategy8.5/10
    Solo-player experience7/10
    Progression and monetisation6.5/10
    Overall8/10

    Final verdict: Pokémon UNITE is easy to start, difficult to master and especially enjoyable with friends. Its ten-minute format makes the MOBA genre more approachable, although matchmaking, team dependence and multiple currencies can sometimes make the experience frustrating.

    The game remains free to download. Optional purchases can speed up some unlocks or buy cosmetics, but beginners can learn the game and build a useful roster without paying.


    Pokémon UNITE at a Glance

    DetailInformation
    GenreMultiplayer online battle arena (MOBA) / team strategy
    Standard format5 players against 5 players
    Standard match lengthApproximately 10 minutes
    PlatformsNintendo Switch, iOS and Android
    PriceFree-to-start with optional in-game purchases
    Cross-platform playYes, between Switch and mobile
    Cross-progressionSupported when the correct account is linked
    Main objectiveCollect Aeos energy and score more points than the opposing team
    Main modesCasual, ranked, quick, custom and Solo Mode, with rotating special modes
    Internet requiredYes

    The official game listing confirms that Pokémon UNITE supports cross-platform battles and allows progress to be synchronised between Switch and mobile through a linked Nintendo Account or Pokémon Trainer Club account. An internet connection is required to play. See the official Pokémon UNITE app listing for the current platform information.


    What Is Pokémon UNITE?

    Pokémon UNITE is Pokémon’s version of a MOBA.

    In a traditional MOBA, each player controls one character with a specific role. Teams fight, gain experience, protect parts of the map and compete for objectives. Pokémon UNITE simplifies this formula and replaces towers and conventional heroes with Pokémon, goal zones and Aeos energy.

    The basic match looks like this:

    1. Ten players are divided into two teams.
    2. Each player chooses one Pokémon.
    3. The Pokémon begins the match at a low level.
    4. Players defeat wild Pokémon and opposing players to gain experience.
    5. Pokémon learn stronger moves and may temporarily evolve.
    6. Defeated wild and opposing Pokémon drop Aeos energy.
    7. Players carry that energy to enemy goal zones and score it.
    8. The team with the highest score at the end wins.

    The official overview describes the same central loop: defeat Pokémon, collect the Aeos energy they drop, deposit it into opposing goal zones and work with teammates to defend your own goals.

    Unlike many longer MOBAs, a standard UNITE match has a fixed time limit. You do not need to destroy an enemy base or wait for a team to surrender. The clock creates a clear ending, which makes the game easier to fit into a short break.


    Is This Really “Catching ’Em All”?

    Not in the traditional sense.

    You do not walk through grass, throw Poké Balls or store captured Pokémon in boxes. Wild Pokémon inside a match are temporary sources of experience and Aeos energy.

    The collection system instead revolves around Unite licenses. A license allows you to select that Pokémon in eligible battles. You gradually expand your playable roster through rewards, events, the License Journey and the in-game shop.

    The game also offers temporary rental licenses, limited free rotations and practice modes. These allow you to try some Pokémon before permanently unlocking them.

    Therefore, the title “catching ’em all in a new way” refers to building a roster of playable Pokémon, not completing a conventional Pokédex.

    This change works surprisingly well because every Pokémon is more than a collectible picture. Unlocking a new license gives you a different playstyle to learn. A Defender such as Slowbro has a completely different job from a mobile Speedster, a ranged Attacker or a healing Supporter.


    How a Pokémon UNITE Match Works

    1. Choose a Pokémon and Battle Position

    Before the match begins, every player selects a Pokémon. A team cannot normally field two copies of the same Pokémon, so you may need a second or third choice if another player picks your favourite first.

    You should also declare where you intend to play:

    • Top path
    • Bottom path
    • Central area

    Declaring a position reduces confusion. If two players both assume they are taking the central area, they may compete for the same wild Pokémon and leave a path without enough help.

    A balanced team usually includes a mixture of damage, durability, mobility and support. Five fragile Attackers may deal considerable damage but can collapse when the opposing team starts a coordinated fight.

    2. Defeat Wild Pokémon

    At the beginning of a match, your Pokémon is relatively weak. Defeating nearby wild Pokémon provides experience and Aeos energy.

    Experience matters because it unlocks:

    • Better statistics
    • New moves
    • Move upgrades
    • Evolution for Pokémon that evolve
    • The Pokémon’s Unite Move

    Do not immediately run towards the opposing team while ignoring nearby wild Pokémon. A one-level advantage can decide an early fight, while falling behind can make it difficult to contest objectives later.

    3. Learn and Upgrade Moves

    Pokémon generally begin with basic moves and choose stronger ones as they level up. The same Pokémon can often choose between two possible moves at each upgrade point.

    This creates different builds. One move combination may provide safer ranged damage, while another may favour close combat, mobility or crowd control.

    Beginners should use the recommended move path for their first few matches. After learning the Pokémon’s basic rhythm, test the alternative moves in practice or Solo Mode.

    4. Collect Aeos Energy

    Wild Pokémon and knocked-out opponents can drop Aeos energy. Walk over the energy to collect it.

    The amount you can carry is limited and generally increases as your Pokémon gains levels. Carrying a large amount creates both an opportunity and a risk:

    • You can score more points if you reach an enemy goal.
    • Scoring takes longer when you carry more energy.
    • If you are knocked out, you may drop part of what you carry.

    This means that repeatedly saving energy for one enormous score is not always the best choice. Several safe scores can be more valuable than carrying a full load into a losing fight.

    5. Score in Enemy Goal Zones

    Stand inside an opposing goal zone and hold the scoring control. The scoring action can be interrupted, so check for nearby enemies first.

    Scoring is easier when:

    • Teammates are nearby
    • Opponents have been knocked out or forced to retreat
    • The goal zone is undefended
    • You carry only a small amount of energy
    • Your team has obtained a scoring-related objective effect
    • An ally helps increase the scoring speed

    Do not dive into a defended goal merely because you are carrying many points. Being knocked out may give the opponent experience and prevent you from helping at the next team objective.

    6. Contest Major Objectives

    Large wild Pokémon appear during a match and provide valuable team-wide advantages. Their identities and effects can vary by map, game mode and season.

    In 2026, ranked play can rotate between versions of Theia Sky Ruins featuring Rayquaza, Groudon or Kyogre. The Pokémon UNITE team has said that these versions require different tactics and selections. The current details are explained in the official February 2026 update notice.

    The evergreen rule is simple:

    Learn what the current objective provides, arrive before it appears and avoid starting it blindly when several opponents are still able to steal the final hit.

    An objective is a team decision, not a private duel. Sometimes the correct play is to attack it. Sometimes it is better to hide nearby, defeat the opponents first or defend it while protecting an existing lead.

    7. Play the Final Stretch Carefully

    The final two minutes are the most dangerous part of a standard match. Scores are normally worth double, and the central Legendary objective may give a powerful advantage.

    Many apparently safe matches are lost here because a leading team becomes impatient.

    If your team is ahead:

    • Do not automatically rush the central objective.
    • Watch the minimap and defend entrances.
    • Keep your Unite Move ready if possible.
    • Prevent opponents from scoring large amounts.
    • Attack the objective only when the situation is favourable.

    If your team is behind:

    • Group instead of entering one at a time.
    • Look for an opponent carrying many points.
    • Contest the central objective together.
    • Use quick chat to coordinate.
    • After winning the objective or team fight, score immediately.

    The final stretch is designed to keep both teams involved until the end. It creates exciting comebacks, but it can also feel punishing when one mistake reverses eight minutes of good play.


    Understanding the Five Pokémon Roles

    Pokémon UNITE separates its roster into five battle types. The official roster describes their general strengths.

    RoleMain jobTypical strengthsMain beginner mistake
    AttackerDeal sustained or burst damageRanged damage, strong movesStanding too close to danger
    SpeedsterMove quickly and eliminate vulnerable targetsMobility, burst damage, scoringEntering fights without an escape
    All-RounderFight for extended periodsBalanced damage and durabilityAssuming “balanced” means unkillable
    DefenderProtect allies and disrupt enemiesHigh endurance, crowd controlChasing kills instead of protecting the team
    SupporterHeal, shield or control fightsTeam utility, recovery, status effectsPlaying alone where support abilities have little value

    Attacker

    Attackers can deal heavy damage but usually have lower endurance. They are often easiest to understand mechanically: stay at a safe distance and attack whatever threatens the team.

    However, an Attacker is not automatically the easiest role. Good positioning is essential. If you move ahead of the Defender, an opposing Speedster may knock you out before you can react.

    Speedster

    Speedsters are mobile Pokémon designed to enter quickly, deal burst damage and escape. They are frequently suited to the central area, where they can level efficiently and move to either path.

    The role is attractive but unforgiving. A Speedster that enters at the wrong time may disappear immediately. Beginners should learn when to wait rather than treating every visible opponent as a target.

    All-Rounder

    All-Rounders combine offence and endurance. They can stay in fights longer than most Attackers and may become powerful after reaching important levels.

    They are useful for players who enjoy direct combat, but each All-Rounder still has unique strengths. Some require careful move combinations; others need time to become strong.

    Defender

    Defenders protect teammates, absorb pressure and prevent opponents from moving freely. They may block routes, stun enemies, push them away or hold an area while allies deal damage.

    The scoreboard may not always make their contribution obvious. A Defender that stops three opponents from reaching a goal can be more useful than a player chasing one unnecessary knockout.

    Supporter

    Supporters improve the performance of the complete team through healing, shields, movement effects or crowd control.

    This role is strongest when teammates cooperate. Solo queue can occasionally be frustrating because a Supporter cannot force allies to remain nearby or act on an opening.

    For a complete beginner, Defender, a durable All-Rounder or a straightforward ranged Attacker is normally easier than a mechanically demanding Speedster or highly team-dependent Supporter.


    Paths and the Central Area

    The main battlefield has a top path, bottom path and central area.

    Top and Bottom Paths

    Path players usually work in pairs. Their early responsibilities are to:

    • Defeat nearby wild Pokémon
    • Share experience sensibly
    • Protect their own goal zone
    • Look for safe opportunities to score
    • Avoid giving the opposing team easy knockouts
    • Rotate towards important objectives

    Do not take every wild Pokémon from your partner, especially if your partner needs one more level to evolve or unlock a key move. A team gains more from two useful players than from one over-levelled player and one under-levelled teammate.

    Central Area

    The central player normally clears the wild Pokémon in the middle, gains levels quickly and then helps one of the paths.

    This role carries responsibility. The central player should not remain hidden in the middle for the whole match. The faster experience is intended to create a temporary advantage that can help a path win a fight or secure neutral wild Pokémon.

    Path players should also avoid taking the first central-area clear unless the team has agreed to a different strategy. Removing that experience can delay the central player’s move upgrades.


    Held Items, Battle Items and Boost Emblems

    Held Items

    A Pokémon can equip up to three held items. These provide passive statistical bonuses or special effects.

    Held items can support different goals, such as:

    • Increasing damage
    • Improving durability
    • Strengthening basic attacks
    • Providing shields or recovery
    • Supporting allies
    • Rewarding successful scoring

    Held items can be upgraded outside battle. Because upgrade materials are limited early on, beginners should avoid raising every item equally.

    Start with a small group of broadly useful items that match the Pokémon you actually play. Check the current recommended sets inside the game, because item strength and popular builds can change after balance updates.

    The official overview confirms that players may equip up to three held items, as well as a separate battle item.

    Battle Items

    A battle item is an active tool with a cooldown. It may provide movement, healing, protection, scoring help or another temporary effect.

    Use battle items deliberately. Do not press one merely because it is available. A mobility item saved for escaping an enemy Unite Move may be more valuable than using it to arrive at a minor wild Pokémon a second earlier.

    Boost Emblems

    Boost emblems provide additional loadout customisation outside battle. Their colour combinations and statistics can become complicated, but beginners do not need a perfect emblem collection before entering normal matches.

    Use a recommended emblem loadout first. Learn positioning, farming and objectives before spending too much time chasing tiny statistical improvements.

    Good decisions normally matter more than a slightly more efficient emblem page.


    Unite Moves

    A Unite Move is a Pokémon’s most powerful ability. It becomes available after reaching the required level and recharges after use.

    Good situations for a Unite Move include:

    • A major objective fight
    • Protecting several teammates
    • Securing multiple knockouts
    • Escaping with a large amount of Aeos energy
    • Stopping a large final-stretch score
    • Creating a safe opportunity for the team to score

    Poor situations include using it against one nearly defeated opponent shortly before a decisive objective appears.

    The exact recharge time differs between Pokémon and can be influenced by battle actions or items. Rather than memorising one universal timer, watch the charge indicator and plan ahead.

    As the final two minutes approach, ask one question: Will I have my Unite Move ready for the most important fight?


    Best Beginner Strategy: What to Do During a Match

    Opening: 10:00 to Around 8:00

    • Follow the path or central-area position you declared.
    • Defeat wild Pokémon efficiently.
    • Avoid stealing your partner’s entire share of experience.
    • Score small amounts when it is safe.
    • Do not chase opponents deep into their side of the map.
    • Watch for the first contested wild Pokémon and early rotations.

    Your goal is not to become the match hero immediately. It is to reach important move and evolution levels without giving the opposing team free experience.

    Middle: Around 8:00 to 5:00

    • Watch objective timers.
    • Move before an objective appears, not after the fight is already lost.
    • Use the minimap to check whether another path needs help.
    • Continue collecting experience between fights.
    • Score when opponents are visible elsewhere.
    • Avoid carrying maximum energy for too long.

    This is where many beginners stop farming and begin wandering from fight to fight. Keep gaining levels. A knockout is only useful when it leads to experience, scoring, an objective or map control.

    Late Middle: Around 5:00 to 2:00

    • Avoid unnecessary solo fights.
    • Save a Unite Move for the final stretch when practical.
    • Break or protect goal zones according to the score situation.
    • Continue watching central and side objectives.
    • Group more frequently with teammates.

    Repeatedly being knocked out now may leave you under-levelled for the decisive fight.

    Final Stretch: 2:00 to 0:00

    • Check whether your team is probably winning or losing.
    • Group near the final objective.
    • Do not attack it without knowing where opponents are.
    • Defeat or zone opponents before committing.
    • After gaining the advantage, score quickly.
    • Return to defend after scoring.

    Do not celebrate before the timer reaches zero. One unattended player carrying 50 energy can score 100 points during the final stretch.


    Ten Beginner Mistakes to Avoid

    1. Treating Every Match Like a Knockout Competition

    Knockouts help, but points decide the match. A player can have many knockouts and still lose by ignoring goals and objectives.

    2. Ignoring the Minimap

    The minimap shows teammates, visible opponents, goals and objective activity. Look at it every few seconds, especially before crossing into enemy territory.

    3. Fighting on the Opponent’s Goal Zone

    Enemy goal zones can help opponents recover and make them harder to defeat. Do not take an equal fight on terrain that favours them unless your team has a clear reason.

    4. Chasing Too Far

    A low-health opponent can be bait. Chasing across the map may separate you from the team, place you near respawning enemies and make you miss an objective.

    5. Arriving Late to Objectives

    Moving only after an objective appears often means arriving after the opposing team has established position. Watch the timer and rotate early.

    6. Starting the Final Objective While Ahead

    If your team is winning, attacking the objective may give the losing team an opportunity to steal it. Defending the area and forcing opponents to approach can be safer.

    7. Scoring While Teammates Are Fighting Nearby

    Sometimes a large score is correct. At other times, your absence turns an even team fight into a four-against-five loss. Read the situation before leaving.

    8. Using the Same Build on Every Pokémon

    An item that helps a physical basic attacker may offer little value to a special-attacking Supporter. Match the loadout to the Pokémon’s mechanics and role.

    9. Refusing to Change Roles

    You may prefer Attackers, but a team with four Attackers often needs durability or support. Learning one Pokémon from at least two roles makes team selection easier.

    10. Blaming Teammates Instead of Reviewing Decisions

    You cannot control matchmaking. You can control positioning, timing, map awareness, communication and whether you enter an unwinnable fight.

    After a loss, identify one decision you could improve. That creates progress even when the complete team played poorly.


    Solo Mode: A Better Place to Learn

    New players who feel nervous about immediate player-versus-player matches can use Solo Mode. Introduced in 2026, this mode provides CPU stages, rewards and several types of challenges.

    It is useful for:

    • Learning controls
    • Testing unfamiliar Pokémon
    • Practising move combinations
    • Understanding scoring
    • Trying held items
    • Learning the basic flow without pressure from teammates

    Solo Mode should not be treated as perfect preparation for ranked play. Human opponents are less predictable, punish mistakes differently and coordinate in ways that CPU players may not.

    Use Solo Mode to become comfortable, then move into casual battles before entering ranked matches.


    Casual, Ranked and Quick Battles

    Casual Battles

    Casual battles use the main rules without placing the same emphasis on rank progression. They are suitable for learning new Pokémon and testing builds.

    Players still want to win, so casual does not mean that objectives and teamwork should be ignored.

    Ranked Battles

    Ranked matches add competitive progression. Winning increases progress while losing can reduce it, depending on the current ranked system and performance rules.

    Enter ranked play when you can:

    • Use at least two or three Pokémon confidently
    • Fill more than one role
    • Understand the current final objective
    • Watch the minimap consistently
    • Avoid unnecessary fights
    • Keep a useful loadout ready

    Do not choose a Pokémon for the first time in ranked mode.

    Quick Battles

    Quick battles take place on smaller maps with shorter timers and modified rules. They are useful when you have little time or want a faster, less conventional match.

    Quick-battle habits do not always transfer directly to standard ranked play, because the maps, team sizes and objectives may differ.

    Rotating and Special Modes

    Pokémon UNITE regularly introduces event modes and unusual rule sets. These can include cooperative battles, sports-like minigames or maps with unique mechanics.

    They provide variety and are often a good place to earn event rewards, but availability can change. Check the current in-game event page instead of relying on an old schedule.


    Is Pokémon UNITE Pay-to-Win?

    The fairest answer is: money can accelerate access and buy cosmetics, while player skill, teamwork and decision-making still determine most matches—but the progression system has attracted reasonable criticism.

    The game contains several currencies and reward systems. Depending on the current version, these may be used for:

    • Unlocking Unite licenses
    • Buying Holowear
    • Purchasing Trainer clothing
    • Upgrading held items
    • Unlocking battle-pass rewards
    • Participating in limited events

    Real money can make some unlocks faster. However, spending money does not teach map awareness, positioning, move timing or objective control.

    The most important beginner rule is not to buy everything immediately.

    Instead:

    1. Use free rotations and practice modes.
    2. Choose two or three Pokémon you genuinely enjoy.
    3. Complete beginner missions and the License Journey.
    4. Invest upgrade resources in a small selection of useful held items.
    5. Treat Holowear as cosmetic spending, not a gameplay requirement.
    6. Set a fixed spending limit before opening the shop.

    Parents should review purchase controls because the game uses optional purchases, event offers and cosmetic rewards that can encourage repeated spending.


    What Pokémon UNITE Does Well

    1. It Makes the MOBA Genre Easier to Approach

    The game removes much of the intimidating complexity found in older MOBAs. There is no large in-match shop filled with recipes, and standard matches have a predictable ten-minute limit.

    The basic goal—collect energy and score—is immediately understandable.

    2. Pokémon Have Strong Identities

    Pokémon do not feel like identical characters wearing different skins. Their movement, attacks, evolutions and Unite Moves reflect their personalities and familiar abilities.

    Seeing a small first-stage Pokémon evolve during the match provides a satisfying sense of growth even though levels reset in the next battle.

    3. Matches Stay Meaningful Until the End

    The final stretch creates genuine tension. A losing team has a reason to continue, while a leading team cannot become careless.

    This comeback system can be frustrating, but it prevents many matches from becoming hopeless halfway through.

    4. Cross-Platform Play Is Convenient

    Switch and mobile players can compete together. Cross-progression also allows a linked account to continue on different devices.

    The Switch version offers physical controls and a television display. The mobile version is convenient and allows interface customisation around touch controls.

    5. Team Composition Matters

    The five-role system creates meaningful cooperation without requiring new players to memorise hundreds of items. A well-timed Defender or Supporter can change a fight as decisively as a high-damage Attacker.

    6. The Roster and Modes Continue to Grow

    New Pokémon, balance adjustments, events and map variations keep the game from remaining static. The official site continues to list new playable Pokémon and game updates in 2026.


    Where Pokémon UNITE Can Be Frustrating

    1. Solo Queue Is Unpredictable

    One player may choose the wrong path, ignore objectives or repeatedly enter fights alone. Because teamwork matters, another player’s decisions can directly affect your result.

    2. The Final Two Minutes Can Feel Too Powerful

    A team that played well for most of the match can lose after one failed final objective fight. The comeback potential creates excitement but can also make the earlier portion feel less important than it should.

    3. The Menus and Currencies Are Busy

    New players may encounter missions, events, currencies, exchanges, item upgrades, passes and notification markers at the same time. The battle itself is easier to understand than the reward interface.

    4. Unlocking a Large Roster Takes Time

    Free players can build a useful roster, but collecting every license is a long-term project. New Pokémon may also be easier to obtain through specific events or paid currency during parts of their release period.

    5. Balance Changes Can Make Old Guides Obsolete

    Move damage, cooldowns, items, maps and objectives can change. A tier list or exact build may become outdated quickly.

    This is why beginners should learn principles—positioning, farming, grouping and objective timing—rather than copying one “best build” forever.


    Nintendo Switch or Mobile: Which Is Better?

    FeatureNintendo SwitchMobile
    ControlsPhysical buttons and analogue stickTouchscreen controls
    ScreenTV or handheld displayPhone or tablet
    PortabilityGoodExcellent
    Interface aimingComfortable after learning controlsDirect touch can feel quick and precise
    CommunicationQuick chat; text entry can be slowerTouch interface can make menus easier
    Best forConsole players and longer sessionsConvenient play anywhere

    Neither platform provides an automatic strategic advantage. Use the one whose controls feel more natural.

    If you plan to use both, link the correct account carefully at the beginning. Account-linking mistakes can create separate progress profiles and may be difficult to reverse.


    Recommended First-Hour Plan

    Follow this sequence instead of entering ranked play immediately:

    1. Complete every basic tutorial.
    2. Open the settings and review targeting and camera controls.
    3. Try several roles in practice or Solo Mode.
    4. Select one durable Pokémon and one ranged Pokémon to learn.
    5. Read every move description.
    6. Use a recommended held-item set.
    7. Play casual matches until scoring and objective timing feel natural.
    8. Practise checking the minimap every few seconds.
    9. Learn the current ranked map and final Legendary objective.
    10. Enter ranked only after you can use more than one Pokémon comfortably.

    This will not make you an expert in one hour, but it prevents the most common early mistakes.


    Beginner Settings Worth Checking

    The exact menu wording may differ between Switch and mobile, but review the following:

    • Advanced attack controls: Separate targeting for opposing players and wild Pokémon can reduce mistakes.
    • Targeting priority: Choose whether the game prioritises low-health targets, nearby targets or another rule.
    • Lock-on icons: Useful when several opponents are grouped together.
    • Move aiming: Adjust sensitivity and aim-assist behaviour.
    • Camera controls: Make it easy to inspect objectives and distant fights.
    • Move-learning selection: Decide whether upgrades should be manual or automatically selected after a delay.
    • Performance settings: Stable frame rate is more valuable than maximum visual quality on a weaker phone.
    • Quick-chat messages: Equip useful calls such as gathering at an objective, defending a goal or retreating.

    Test settings in practice mode. Changing several controls during a real match can distract you from the team.


    Is Pokémon UNITE Suitable for Children?

    Pokémon UNITE is colourful and does not present realistic violence, but it is an online competitive game.

    Parents should consider:

    • Matches involve other online players.
    • Voice chat and other communication features may be available subject to account, age and system restrictions.
    • Optional in-game purchases are present.
    • Limited-time rewards can encourage frequent play.
    • Competitive losses may be frustrating for younger players.
    • A stable internet connection is required.

    Useful family rules include:

    • Require permission before any purchase.
    • Protect the platform account with a password or purchase PIN.
    • Disable or restrict voice communication where appropriate.
    • Agree on a match limit instead of an unclear time limit, because leaving midway disadvantages the complete team.
    • Explain that poor behaviour from another player should be muted or reported, not answered.

    Solo Mode and CPU battles are good starting points for a child who wants to learn without pressure from other players.


    Frequently Asked Questions

    Is Pokémon UNITE free?

    Yes. It is a free-to-start game with optional in-game purchases. You can download it and play without buying the game upfront.

    Do I need Nintendo Switch Online?

    Free-to-play Nintendo Switch games generally do not require a paid Nintendo Switch Online membership for online play. Check the current Nintendo store page and regional account terms in case platform policies change.

    Can mobile and Switch players play together?

    Yes. Pokémon UNITE supports cross-platform battles between Nintendo Switch and compatible mobile devices.

    Can I use the same progress on Switch and mobile?

    Yes, when the account is linked correctly through a supported Nintendo Account or Pokémon Trainer Club account. Follow the linking instructions before creating separate progress on both platforms.

    How long is a Pokémon UNITE match?

    A standard match lasts about ten minutes. Quick battles are shorter and special modes may use different timers.

    Do Pokémon levels carry into the next match?

    No. Match levels reset. Every battle begins with the Pokémon at a low level so both teams can develop during that match.

    Do Pokémon evolve permanently?

    No. Evolution during a Unite Battle is temporary. The Pokémon returns to its normal pre-battle state after the match.

    Can I catch wild Pokémon?

    You defeat wild Pokémon for experience and Aeos energy, but you do not permanently capture them. Playable Pokémon are unlocked through Unite licenses.

    What is the easiest role for a beginner?

    A durable Defender, a straightforward All-Rounder or a simple ranged Attacker is normally a good starting point. The best choice is one whose move descriptions and battle distance you understand.

    Should beginners play the central area?

    They can, but the position carries responsibility. The central player must clear efficiently, help paths and arrive at objectives. A path position may be easier while learning the map.

    When should I start ranked matches?

    Start when you understand scoring, can use several Pokémon, know the current map objectives and can follow a declared path without becoming lost.

    Is the game pay-to-win?

    Purchases can speed up some unlocks and buy cosmetics, but money cannot replace mechanical skill, positioning, teamwork or map awareness. The upgrade and currency systems can still create progression concerns, so free players should invest resources carefully.

    What happens if there is a tie?

    Under the established standard rule, the team that reached the tied final score first wins. Rules can vary in special modes.

    Is voice chat required?

    No. Quick-chat signals and minimap awareness are enough for ordinary play. Voice communication can help organised teams but should be used with appropriate privacy and parental controls.

    Which Pokémon is currently the strongest?

    There is no permanent answer. Balance patches, map rotations and team compositions change the metagame. Choose a Pokémon you can play consistently and check recent in-game recommendations or official update notes.

    Is Pokémon UNITE still worth playing in 2026?

    Yes, particularly for players who want short team battles, enjoy Pokémon or want a more approachable MOBA. The growing roster, Solo Mode, License Journey and rotating Theia Sky Ruins objectives give new players several ways to begin.


    Final Verdict

    Pokémon UNITE succeeds because it keeps the excitement of Pokémon growth while removing the slower structure of a traditional role-playing game.

    A match begins simply: choose a Pokémon, defeat wild opponents and score energy. Under that simple surface are positioning, team composition, move timing, level advantages, objective control and final-stretch decisions.

    The game is at its best when five players understand their roles and move together. It is at its worst when matchmaking produces a disorganised team or when one final objective reverses an otherwise controlled match.

    The progression screens and currencies could be clearer, and collecting the complete roster requires patience. However, the actual battles remain fast, responsive and recognisably Pokémon.

    Pokémon UNITE is recommended for:

    • Pokémon fans who want real-time team battles
    • Players curious about MOBAs but intimidated by longer games
    • Friends looking for a free cross-platform game
    • Players who enjoy learning several roles and strategies
    • Anyone who prefers fixed ten-minute matches

    It may not suit:

    • Players looking for a traditional Pokémon adventure
    • People who dislike team-dependent competitive games
    • Players who want to unlock every character immediately
    • Anyone who cannot maintain a stable internet connection

    The best way to judge it is to download it, complete the tutorials and play several Solo Mode or casual matches. There is no upfront purchase, and the core battle system becomes clear quickly.

    Overall score: 8/10.

    Pokémon UNITE does not let you catch them all in the usual way. It lets you build a roster, learn what makes each Pokémon valuable and discover how five very different partners can work as one team.

  • Best Practices for Migrations in Microservices: A Zero-Downtime Guide

    Migrating a microservices system is rarely a single database command or one deployment.

    A real migration may change several things at the same time:

    • Which service owns a business capability
    • Which database stores the data
    • How APIs communicate
    • Which events are published and consumed
    • How existing records are transformed
    • How traffic moves from old code to new code
    • How the team detects errors and recovers

    The difficult part is not creating the new table, endpoint or service. The difficult part is keeping the system correct while old and new versions run together.

    This guide explains the main best practices for planning and performing microservice migrations with minimal downtime and a practical recovery path.


    Quick Answer

    The safest microservice migrations are small, backward-compatible and observable.

    Use this general sequence:

    1. Discover the real dependencies and define the migration boundary.
    2. Expand the system by adding compatible schemas, APIs or events.
    3. Synchronise old and new paths while existing data is copied.
    4. Verify data, behaviour, performance and security continuously.
    5. Shift traffic gradually using flags, routing rules or canary releases.
    6. Stabilise the new path while retaining a tested recovery option.
    7. Contract by removing obsolete fields, endpoints, topics and code only after all consumers have moved.

    This is often called expand and contract or parallel change. The important idea is that a breaking change is divided into several compatible releases instead of being performed all at once. Martin Fowler describes the three broad stages as expand, migrate and contract in his explanation of Parallel Change.

    RiskSafer practice
    Big-bang replacementIncremental strangler migration
    Renaming or deleting a column immediatelyAdd, backfill, switch and remove later
    Copying millions of rows in one transactionSmall resumable batches
    Writing to a database and broker separatelyTransactional outbox or CDC
    Assuming messages arrive onceIdempotent consumers and deduplication
    Changing an API response without checking clientsBackward-compatible changes and contract tests
    Deploying all traffic to new codeCanary, percentage rollout or routing by tenant
    Treating rollback as “deploy the old image”Design data-aware rollback or roll-forward
    Declaring success after deploymentReconcile data and monitor business metrics

    What Does “Migration” Mean in Microservices?

    The word migration can describe several different operations.

    1. Service Migration

    A business capability is moved from a monolith or an existing service into a new microservice.

    Example:

    Monolith order module
            ↓
    New Order Service

    2. Database Schema Migration

    The structure of a service-owned database changes.

    Examples include:

    • Adding a column
    • Creating an index
    • Splitting one table into several tables
    • Changing a data type
    • Adding a constraint
    • Removing an obsolete table

    3. Data Migration

    Existing records are copied, transformed or reassigned to a different service.

    Example:

    monolith.customers
            ↓
    customer_service.customers

    4. API Migration

    Consumers move from an old HTTP, gRPC or GraphQL contract to a new one.

    5. Event Migration

    Producers and consumers move to a new event name, payload, topic or schema version.

    6. Infrastructure Migration

    The service moves to a new cluster, region, cloud account, runtime or deployment platform.

    These migrations often overlap. Extracting an Order Service, for example, can involve a new database, a historical backfill, a new API, new events, traffic routing and changes to several consumers.

    That is why the migration should be managed as a sequence of controlled state transitions rather than one deployment ticket.


    Why Microservice Migrations Are Difficult

    In a monolith, code and database changes can sometimes be released together. In a microservices environment, independent deployment creates a mixed-version period:

    • Old application instances may still be serving traffic.
    • New instances may already use the new schema.
    • Some consumers may understand a new event field while others do not.
    • A mobile application may continue using an old API for months.
    • Delayed messages may contain an older event format.
    • Backfill jobs may still be copying historical records.
    • Cached data may represent the old ownership model.

    The system must remain correct throughout this period.

    A migration can therefore fail even when every individual component appears healthy. Typical failure modes include:

    • Lost writes during data copying
    • Duplicate messages during retries
    • Old consumers rejecting a new payload
    • Long database locks causing request timeouts
    • Partial data movement between services
    • Two databases disagreeing about the same entity
    • Rollback code being unable to understand data written by the new version
    • A silent business error that produces no infrastructure alert

    The objective is not merely “the deployment completed.” The objective is that the new system is correct, recoverable and measurably better or at least equivalent.


    1. Define the Boundary and Success Criteria First

    Do not start with migration scripts. Start with the reason and the boundary.

    Write a short migration definition containing:

    • The business capability being moved
    • The present owner and future owner
    • The source and destination data stores
    • All known API consumers
    • All event producers and consumers
    • Expected traffic and data volume
    • Availability and latency targets
    • Acceptable data lag during transition
    • Recovery point objective and recovery time objective
    • Compliance, audit and retention requirements
    • Clear completion and abort conditions

    Example success criteria:

    - 100% of order-creation traffic reaches Order Service.
    - Reconciliation mismatch stays below 0.01% for seven days.
    - No unexplained missing or duplicate orders exist.
    - p95 creation latency remains below 400 ms.
    - Error rate remains below 0.5%.
    - Every known consumer has stopped reading the legacy order tables.
    - The rollback or roll-forward runbook has been tested.

    “Move orders to a microservice” is not a testable success criterion. Precise thresholds create an objective go/no-go decision.


    2. Discover Dependencies Before Changing Ownership

    Documentation is useful, but the real dependency graph may be larger than the documented one.

    Inspect:

    • Source-code references
    • Database queries and stored procedures
    • Foreign keys, views and triggers
    • Scheduled jobs and ETL pipelines
    • Reports and business-intelligence tools
    • API gateway and reverse-proxy routes
    • Event topics and consumer groups
    • Cache keys
    • Search indexes
    • Data exports and partner integrations
    • Support and administrative tools
    • Manual operational procedures

    Combine static discovery with runtime evidence:

    • Distributed traces
    • Database query logs
    • API access logs
    • Broker consumer information
    • Network telemetry
    • Audit logs

    For every dependency, record:

    ItemExample
    OwnerBilling team
    DependencyReads orders.status directly
    InterfaceShared PostgreSQL table
    CriticalityRequired for invoice generation
    Migration actionReplace with Order API or event projection
    DeadlineBefore legacy table becomes read-only
    VerificationCompare invoice count and total value

    Hidden database readers are especially dangerous. A service cannot truly own its data while unrelated services continue to query its tables directly.


    3. Give Each Service Clear Data Ownership

    The usual target is not necessarily one physical database server per service. The important rule is logical ownership:

    • One service controls the schema.
    • Other services do not write its tables.
    • Other services obtain information through published contracts.
    • Schema changes are made by the owning team.

    Depending on scale and risk, services may use:

    • Separate database servers
    • Separate database instances
    • Separate databases on the same server
    • Separate schemas with strictly controlled permissions

    The last option can be an intermediate step, but it requires access controls that prevent accidental cross-service queries.

    Avoid a Distributed Monolith

    Moving code into several repositories while retaining one freely shared database does not create independent services. It can produce the operational cost of microservices without the isolation benefits.

    Common warning signs are:

    • Service A joins Service B’s tables.
    • Several services run migrations against the same schema.
    • A column cannot be changed without coordinating many teams.
    • Business rules are enforced by undocumented cross-service database triggers.
    • One database outage stops every service.

    During an incremental migration, temporary sharing may be unavoidable. Make it explicit, time-limited and measurable. Track every temporary dependency to removal.


    4. Prefer Incremental Migration Over a Big-Bang Rewrite

    A full rewrite creates a long period in which the new system receives little production feedback. It also concentrates data, code and operational risk into one cutover.

    The strangler fig pattern replaces capabilities gradually. A routing layer sends selected requests to the new service while the rest continue to use the legacy system. AWS describes this as a way to reduce transformation risk and business disruption during monolith-to-microservice migration in its Strangler Fig guidance.

    A typical progression is:

    Stage 1: All traffic → Legacy system
    Stage 2: Selected operation or tenant → New service
    Stage 3: Most traffic → New service, fallback retained
    Stage 4: All traffic → New service
    Stage 5: Legacy capability removed

    Choose a first migration slice that has:

    • A clear business boundary
    • Manageable data volume
    • Few synchronous dependencies
    • Measurable outcomes
    • Real value even if later migrations pause

    Do not automatically start with the smallest module. A technically small module with ten hidden consumers may be riskier than a larger but well-isolated capability.


    5. Use Expand-and-Contract Database Changes

    A destructive schema change should normally span multiple releases.

    Suppose customers.full_name must become display_name.

    The unsafe approach is:

    ALTER TABLE customers
    RENAME COLUMN full_name TO display_name;

    Old application instances may still query full_name and fail immediately.

    Use the following phases instead.

    Phase A: Expand

    Add the new structure without removing the old one:

    ALTER TABLE customers
    ADD COLUMN display_name VARCHAR(200) NULL;

    Deploy code that can tolerate both schemas. Depending on the application and database, it may temporarily write both fields or use a compatibility layer.

    Phase B: Backfill

    Copy existing values in small batches:

    UPDATE customers
    SET display_name = full_name
    WHERE id > :last_id
      AND id <= :batch_end_id
      AND display_name IS NULL;

    Make the operation resumable. Store progress, limit the batch size and monitor database load.

    Phase C: Switch Reads

    Deploy code that reads display_name. Keep the old field available while older instances and consumers are retired.

    Phase D: Verify

    Check for missing or conflicting values:

    SELECT COUNT(*) AS missing_values
    FROM customers
    WHERE display_name IS NULL;

    If the two columns are expected to match during transition:

    SELECT COUNT(*) AS mismatched_values
    FROM customers
    WHERE display_name IS DISTINCT FROM full_name;

    Use the equivalent null-safe comparison for the selected database engine.

    Phase E: Contract

    Only after all code versions and consumers have moved should the old column be removed:

    ALTER TABLE customers
    DROP COLUMN full_name;

    The contract stage may be days or weeks later. That delay is intentional.


    6. Keep Schema Changes Backward-Compatible During Rolling Deployments

    During a rolling deployment, old and new instances coexist. Both versions must work with the current schema.

    Usually safer changes include:

    • Adding a nullable column
    • Adding a table that old code ignores
    • Adding an optional response or event field
    • Adding a non-unique index using the database’s online or concurrent mechanism

    Potentially breaking changes include:

    • Dropping or renaming a column
    • Changing a field’s meaning
    • Changing a data type in place
    • Adding NOT NULL before data is populated
    • Replacing a default while old code depends on it
    • Tightening a constraint before invalid historical records are repaired

    GitLab’s zero-downtime migration guidance highlights a subtle example: older processes can retain stale schema information and depend on a database default while a post-deployment migration removes it. The later insert can then fail. This illustrates why compatibility must be evaluated against every live application version, not just the new one. See Avoiding downtime in migrations.

    Adding a Required Column Safely

    Do not add a mandatory field and expect every existing row and old instance to satisfy it immediately.

    Use this sequence:

    1. Add the column as nullable or with a compatible temporary default.
    2. Deploy writers that populate it.
    3. Backfill historical rows.
    4. Verify that no invalid rows remain.
    5. Add the constraint using an engine-appropriate low-lock method.
    6. Remove a temporary default only after old writers are gone.

    Creating Indexes Carefully

    Index creation on a large busy table can block writes or consume significant I/O and CPU.

    PostgreSQL supports:

    CREATE INDEX CONCURRENTLY idx_orders_customer_id
    ON orders (customer_id);

    The PostgreSQL documentation states that CREATE INDEX CONCURRENTLY can create an index without locking out writes, although it takes more work and has restrictions. Check the exact behaviour and failure recovery for the database version in use. See the official CREATE INDEX documentation.

    Do not copy PostgreSQL syntax into MySQL, SQL Server or another engine. Each database has different online DDL capabilities and locking behaviour.


    7. Separate Schema Migration from Large Data Backfills

    A schema migration should normally be short and predictable. Updating millions of records inside the deployment migration can cause:

    • Long locks
    • Transaction-log growth
    • Replica lag
    • Increased I/O
    • Request latency
    • Deployment timeout
    • Difficult recovery after partial failure

    Use a background migration for large data changes.

    A good backfill worker should be:

    • Batched: processes a limited number of records at a time
    • Resumable: continues from a checkpoint after interruption
    • Idempotent: safely processes a record more than once
    • Throttled: slows down when database health degrades
    • Observable: exposes progress, failures and estimated completion
    • Auditable: records the migration version and transformation rule
    • Cancellable: can stop without corrupting partially processed data

    GitLab recommends batched background migrations when data migrations exceed its normal migration time limits. Its batched background migration documentation is a useful production example.

    Example Backfill Loop

    repeat:
        claim the next ID range
        update no more than 1,000 rows
        commit
        record the checkpoint
        measure latency and replica lag
        pause if safety limits are exceeded
    until no rows remain

    Prefer a stable indexed cursor such as the primary key over deep OFFSET pagination. Offset queries become increasingly expensive and can behave unpredictably while rows are inserted or deleted.

    Do not assume one batch size is correct for every environment. Tune it using production-like data and real monitoring.


    8. Run Migrations as a Controlled Deployment Step

    Avoid allowing every application replica to run migrations automatically at startup. If 20 pods start together, they may compete for locks, repeat work or leave the deployment in an unclear state.

    A safer pattern is one controlled migration job that:

    1. Acquires an advisory or migration lock.
    2. Checks the expected current schema version.
    3. Runs a bounded migration.
    4. Records success or failure.
    5. Releases the lock.
    6. Allows the application rollout to proceed only when appropriate.

    In Kubernetes, a Job represents a one-off task that runs to completion, according to the official Kubernetes Job documentation. Teams commonly use a dedicated Job or an equivalent release phase for migrations.

    An init container can also run setup before an application container, but attaching a database migration to every pod startup can create concurrency and rollout problems. Choose the mechanism based on deployment guarantees, not convenience.

    Additional controls should include:

    • One migration owner per service
    • Explicit timeouts
    • Lock timeouts
    • Statement timeouts
    • Preflight checks
    • An append-only migration history
    • Checksums or immutability for released migration files
    • Alerts on failed or unusually slow migrations

    Never silently edit a migration that has already run in production. Add a new corrective migration.


    9. Move Data with Snapshot, Change Capture and Reconciliation

    Copying a table while the application is writing to it creates a moving target.

    A robust live-data migration often uses four stages.

    Stage 1: Initial Snapshot

    Copy the historical records from the source to the destination.

    Preserve stable identifiers where possible. Record the snapshot boundary, such as a log sequence number, timestamp or change-stream offset.

    Stage 2: Capture Ongoing Changes

    While the snapshot runs, capture inserts, updates and deletes that occur in the source.

    Possible methods include:

    • Change data capture from the database log
    • A transactional outbox
    • Existing domain events, if their completeness has been proven
    • A temporary compatibility layer

    Stage 3: Catch Up and Reconcile

    Apply captured changes until lag is acceptably low. Then compare source and destination.

    Useful checks include:

    • Total row count
    • Count by tenant, date or status
    • Sum of important monetary fields
    • Minimum and maximum identifiers
    • Null and constraint violations
    • Hashes of canonical record representations
    • Random and risk-based samples
    • Business outcomes, such as number and value of completed orders

    A single total row count is not enough. Two datasets can have the same number of rows and different contents.

    Stage 4: Cut Over Ownership

    Move reads and writes to the destination gradually. Continue monitoring changes and discrepancies during a defined confidence period.

    The cutover plan must state which system is authoritative at every stage. Avoid a period in which teams cannot answer, “If the two values disagree, which one wins?”


    10. Avoid Unsafe Dual Writes

    Consider this code:

    save order in database
    publish OrderCreated event

    Two failures are possible:

    1. The database commit succeeds but event publication fails.
    2. The event is published but the database transaction later fails.

    Retrying the whole operation can also create duplicates.

    This is the dual-write problem. The AWS transactional outbox guidance explains how separate database and message operations can leave distributed systems inconsistent.

    Transactional Outbox Pattern

    Write the business record and an outbox record in the same local database transaction:

    BEGIN;
    
    INSERT INTO orders (
        id,
        customer_id,
        status,
        total_amount
    ) VALUES (
        :id,
        :customer_id,
        'created',
        :total_amount
    );
    
    INSERT INTO outbox_events (
        event_id,
        aggregate_type,
        aggregate_id,
        event_type,
        payload,
        created_at
    ) VALUES (
        :event_id,
        'order',
        :id,
        'OrderCreated',
        :payload,
        CURRENT_TIMESTAMP
    );
    
    COMMIT;

    A separate relay or CDC connector publishes committed outbox rows to the broker. Debezium provides an Outbox Event Router specifically for this pattern.

    The outbox improves reliability, but consumers must still expect duplicates. The relay may publish an event and fail before recording completion.

    Idempotent Consumer

    Give every event a stable ID. The consumer records processed IDs in the same transaction as its local state change:

    BEGIN;
    
    INSERT INTO processed_messages (
        consumer_name,
        event_id,
        processed_at
    ) VALUES (
        'billing-service',
        :event_id,
        CURRENT_TIMESTAMP
    )
    ON CONFLICT DO NOTHING;
    
    -- Continue only if the insert affected one row.
    -- Apply the business update here.
    
    COMMIT;

    The exact SQL differs by database. The important property is that deduplication and the local business update succeed or fail together.


    11. Replace Cross-Service Transactions with Explicit Workflows

    Once each service owns its database, a single ACID transaction normally cannot cover the complete business process.

    For example:

    Create order → Reserve stock → Authorise payment → Arrange shipment

    Use a saga when several local transactions form one distributed business workflow.

    A saga defines:

    • The forward steps
    • The responsible service for each step
    • Retry rules
    • Timeouts
    • Idempotency behaviour
    • Compensating actions
    • The point after which compensation is no longer appropriate
    • The final failure states visible to users and operators

    Example compensation:

    If payment fails after stock is reserved:
        release the reservation
        mark the order as payment_failed

    Compensation is a business action, not a database rollback. Refunding a payment is a new auditable transaction; it does not erase the original charge.

    The Azure Saga pattern guidance notes that retryable saga operations should be idempotent so a temporary failure does not create repeated side effects.

    Use choreography for small, understandable event flows. Consider orchestration when the process has many participants, deadlines, complex compensation or a strong need for central visibility. Neither style removes the need for service autonomy, tracing and failure handling.


    12. Evolve APIs Without Breaking Consumers

    Service providers and consumers are rarely deployed at exactly the same time.

    Prefer additive changes:

    • Add an optional field.
    • Add a new endpoint or operation.
    • Accept both old and new input representations temporarily.
    • Preserve existing status codes and field meanings.
    • Use tolerant readers where appropriate.

    Treat these as breaking changes unless proven otherwise:

    • Removing or renaming a field
    • Changing a field from optional to required
    • Changing units, timezone or semantic meaning
    • Returning a different error code
    • Changing empty data from [] to null
    • Changing pagination behaviour
    • Reusing a field for a different purpose

    Version Only When Necessary

    API versioning can help manage unavoidable breaking changes, but it is not a replacement for compatibility discipline.

    When a new version is necessary:

    • Publish a deprecation policy.
    • Identify every consumer.
    • Measure usage of the old version.
    • Provide a migration guide.
    • Set a realistic removal date.
    • Alert owners who still use the old contract.
    • Keep both versions until the exit criteria are met.

    Use Consumer-Driven Contract Tests

    Contract tests verify the behaviour that each consumer actually depends on. They can reject a provider deployment that breaks a known consumer before production.

    Pact describes itself as a code-first consumer-driven contract testing tool and generates contracts from consumer tests. See the official Pact introduction.

    Contract tests complement—not replace—unit, integration, security, performance and end-to-end tests.


    13. Treat Event Schemas as Long-Lived Contracts

    An event can remain in a topic, retry queue, archive or replay system long after the producer deployment.

    Every event should have a clear envelope, for example:

    {
      "event_id": "01JXYZ...",
      "event_type": "OrderCreated",
      "schema_version": 2,
      "occurred_at": "2026-08-06T08:30:00Z",
      "correlation_id": "req-12345",
      "producer": "order-service",
      "aggregate_id": "ord-9081",
      "payload": {
        "customer_id": "cus-42",
        "currency": "MYR",
        "total_amount": "129.90"
      }
    }

    Best practices include:

    • Use stable event IDs.
    • Include an event occurrence time, not only publication time.
    • Preserve correlation and causation information.
    • Add optional fields instead of changing existing meanings.
    • Never reuse an event name for different semantics.
    • Do not renumber or reuse removed Protobuf field numbers.
    • Validate compatibility in CI.
    • Test old consumers against new producer schemas.
    • Retain readers for historical formats when replay is required.

    Confluent’s schema evolution documentation distinguishes backward, forward and full compatibility. Select the compatibility rule based on deployment and replay requirements rather than relying blindly on a registry default.

    Events Should Describe Facts

    Prefer:

    OrderCancelled

    over an ambiguous database-shaped event such as:

    OrdersTableRowUpdated

    A domain event should express what happened. Exposing internal table structure couples consumers to the producer’s storage model and makes future migrations harder.


    14. Make Retries Safe

    Retries are normal in distributed systems. Timeouts do not prove that an operation failed; the remote service may have completed it before the response was lost.

    For commands that cause side effects, accept an idempotency key:

    POST /payments
    Idempotency-Key: checkout-874-payment-1

    The receiving service stores the key and the result. A retry returns the original result or safely resumes the operation instead of creating another payment.

    The same principle applies to:

    • Backfill batches
    • Event consumers
    • Saga steps
    • File imports
    • Webhook handlers
    • Traffic replay

    Azure’s microservice assessment guidance describes deriving and storing idempotency keys so retried work can be detected and skipped safely. See Microservices assessment and readiness.

    Do not claim “exactly once” merely because the broker offers an exactly-once feature. End-to-end behaviour also includes databases, external APIs and application side effects. Design the business operation itself to tolerate repetition.


    15. Control Cutover with Flags and Routing

    A migration should have a control plane that allows operators to change behaviour without an emergency rebuild.

    Useful controls include:

    • Feature flag by tenant or user cohort
    • Percentage-based traffic routing
    • Route by endpoint
    • Route by region
    • Read-source selection
    • Write-path selection
    • Event-consumer enablement
    • Shadow traffic
    • Emergency kill switch

    A sensible rollout might be:

    Internal users → 1% → 5% → 25% → 50% → 100%

    Promotion should depend on measurements, not a fixed timer alone.

    At each stage, compare:

    • Error rate
    • Latency percentiles
    • Saturation
    • Data mismatch rate
    • Event lag
    • Duplicate rate
    • Business conversion or completion rate
    • Support incidents

    Shadow Traffic

    Shadowing sends a copy of production requests to the new service but does not use its response for the user.

    It is useful for checking:

    • Compatibility
    • Performance
    • Result differences
    • Unexpected input shapes

    Shadow requests must not create real side effects. Disable writes or direct them to an isolated destination. Remove or protect personal and secret data according to the organisation’s security requirements.


    16. Build Observability Before Cutover

    Do not wait for a migration failure to decide what should be measured.

    Technical Metrics

    • Request rate, error rate and duration
    • Database CPU, I/O, locks and connection usage
    • Query latency
    • Replica lag
    • Queue depth and consumer lag
    • Retry and dead-letter counts
    • Backfill throughput and remaining records
    • CDC lag
    • Cache hit rate
    • Resource saturation

    Migration Metrics

    • Percentage of traffic on the new path
    • Records copied
    • Records remaining
    • Source-destination mismatches
    • Writes handled by each path
    • Old API or event-schema usage
    • Number of unknown consumers
    • Compensation rate

    Business Metrics

    • Orders created
    • Payments completed
    • Inventory reserved
    • Invoices generated
    • Total monetary value
    • User completion rate

    A service can return HTTP 200 while calculating the wrong total. Business invariants often detect migration defects faster than infrastructure metrics.

    Use consistent correlation IDs across HTTP calls, events, logs and saga steps. Distributed traces help reveal which path processed a request, but traces should be combined with metrics, logs and reconciliation reports.


    17. Design Rollback Before Deployment

    “Redeploy the previous image” is not a complete rollback plan.

    After a migration begins, the new version may have:

    • Written data the old version cannot understand
    • Published events old consumers cannot parse
    • Moved ownership to another database
    • Triggered external side effects
    • Applied an irreversible schema change

    For each phase, decide whether the response is:

    • Traffic rollback: route users back to the old path
    • Code rollback: deploy the previous compatible version
    • Data restore: restore a verified backup or point-in-time copy
    • Compensation: perform a business reversal
    • Roll-forward: fix the new path while preserving already committed state

    Many production migrations are safer to roll forward after data has changed. The runbook should state the point of no return and the approved response after it.

    A Useful Rollback Matrix

    PhaseFailureResponse
    Expand schemaMigration lock timeoutStop migration; application remains on old schema
    BackfillHigh replica lagPause worker and resume from checkpoint later
    Shadow trafficResult mismatchKeep responses non-authoritative; investigate
    5% cutoverError rate exceeds limitRoute traffic back to legacy path
    New writes authoritativeConsumer defectFix or roll forward; replay retained events
    Old column removedOld code requestedRestore only if recovery plan supports it; otherwise roll forward

    Test the recovery path in a production-like environment. An untested rollback plan is an assumption.


    18. Secure the Migration Path

    Temporary migration components often receive broad access and may be forgotten after cutover.

    Apply normal production security controls:

    • Least-privilege source and destination credentials
    • Separate identities for schema migration, backfill and application runtime
    • Encrypted connections and storage
    • Secret rotation
    • Audit logging
    • Network restrictions
    • Masked or synthetic non-production data
    • Access expiry for temporary tools
    • Validation and safe parsing of imported records
    • Approval for destructive operations

    Do not place credentials in migration scripts, container images or command history.

    If personal data moves to a new service, update:

    • Data inventory
    • Retention and deletion processes
    • Data-subject request workflows
    • Encryption-key ownership
    • Backup and restore scope
    • Regional residency controls
    • Audit evidence

    Migration completion includes removing temporary accounts, firewall rules, topics, buckets and data copies.


    19. Test the Migration as a State Machine

    Testing only the final architecture misses the riskiest part: intermediate states.

    Test at least these states:

    Old code + old schema
    Old code + expanded schema
    Old and new code + expanded schema
    New code + partially backfilled data
    New code + completed backfill
    New code + contracted schema

    Important test categories include:

    Migration Tests

    • Upgrade from a realistic previous production version
    • Re-run an already completed migration
    • Interrupt and resume a backfill
    • Apply the migration to production-sized data
    • Verify lock and duration limits
    • Verify downgrade or roll-forward behaviour

    Contract Tests

    • Old consumer against new provider
    • New consumer against old provider during the transition
    • Old event reader against new producer schema
    • Replay historical events through the new consumer

    Failure Tests

    • Database unavailable during a batch
    • Broker publish timeout
    • Duplicate event delivery
    • Out-of-order event delivery
    • Consumer crash after business commit but before acknowledgement
    • CDC pause and recovery
    • Network partition during cutover
    • Partial destination outage

    Reconciliation Tests

    • Intentional missing row is detected
    • Intentional field mismatch is detected
    • Duplicate record is detected
    • Monetary totals use correct precision and currency
    • Deleted records or tombstones are handled correctly

    Use anonymised production-like volume and data distribution. A migration that completes in seconds on 1,000 test rows may behave very differently on 500 million production rows.


    20. Create a Migration Runbook

    The runbook should be executable by an engineer who did not design the migration.

    Include:

    1. Purpose and scope
    2. Owners and communication channel
    3. Architecture before and after
    4. Dependency inventory
    5. Preconditions
    6. Backup and restore verification
    7. Exact deployment sequence
    8. Flags and routing controls
    9. Validation queries
    10. Dashboards and alert links
    11. Go/no-go thresholds
    12. Pause, abort and roll-forward instructions
    13. Escalation contacts
    14. Expected duration for each stage
    15. Cleanup tasks
    16. Evidence to retain

    Conduct a rehearsal. Record actual timings and revise the runbook instead of assuming the first plan is accurate.

    For a high-risk migration, assign explicit roles:

    • Migration lead
    • Database operator
    • Service owner
    • Observability lead
    • Business validator
    • Incident commander if thresholds are breached
    • Communications owner

    This reduces confusion during a time-sensitive cutover.


    Complete Example: Extracting an Order Service

    Assume a monolith owns order creation, and the goal is a new Order Service with its own database.

    Step 1: Discover

    The team finds these dependencies:

    • Checkout creates orders.
    • Billing reads order totals.
    • Warehouse reads new orders from a scheduled query.
    • Customer support searches the monolith database.
    • Finance exports completed orders nightly.

    The warehouse job and support search were not present in the original architecture diagram.

    Step 2: Establish the Contract

    Create:

    • POST /orders
    • GET /orders/{id}
    • OrderCreated
    • OrderStatusChanged

    Add contract tests for checkout and billing. Design event fields as backward-compatible schemas.

    Step 3: Prepare the Destination

    Create the Order Service database and schema. Set service-specific credentials. Add dashboards, alerts and audit logs before sending production traffic.

    Step 4: Backfill History

    Copy orders in stable ID batches. Record each checkpoint and the source change-stream position.

    For every batch:

    • Insert or update idempotently.
    • Compare count and monetary total.
    • Log invalid records separately.
    • Stop automatically if error or database-load thresholds are exceeded.

    Step 5: Capture New Changes

    Use CDC or a transactional outbox to transfer changes that occur after the snapshot boundary.

    Measure:

    • Change lag
    • Failed records
    • Duplicate deliveries
    • Source-destination mismatch

    Step 6: Shadow Reads

    The production response still comes from the monolith, but the application also queries Order Service asynchronously and compares canonical results.

    Differences are categorised instead of merely counted:

    • Expected formatting difference
    • Timing or eventual-consistency difference
    • Missing record
    • Incorrect status
    • Incorrect financial value
    • Unknown

    Step 7: Move Consumers

    Move billing, warehouse, support and finance to the published API or event projections. Deny new direct database integrations.

    Monitor legacy access logs until no known consumer reads the old tables.

    Step 8: Shift Reads

    Move internal users first, followed by 1%, 5%, 25%, 50% and 100% of eligible traffic. Hold each stage until the defined error, latency and mismatch thresholds pass.

    Step 9: Shift Writes

    Make Order Service the authoritative writer. Ensure commands are idempotent and events use the transactional outbox.

    Do not allow both systems to accept unrelated authoritative writes. If a transitional write path is needed, define one owner and one replication direction.

    Step 10: Stabilise

    Run at 100% while retaining the ability to route reads or commands according to the approved recovery plan. Continue reconciliation for the agreed confidence period.

    Step 11: Contract and Clean Up

    After exit criteria pass:

    • Remove legacy writes.
    • Revoke old database access.
    • Disable temporary CDC or replication if no longer required.
    • Archive or remove obsolete tables according to retention policy.
    • Remove old flags, routes and code.
    • Update architecture and operational documentation.
    • Record final evidence and lessons learned.

    The migration is complete only after cleanup. Permanent compatibility code and unused data pipelines become future failure points.


    Common Microservice Migration Mistakes

    1. Performing the Schema and Code Change in One Release

    This assumes every instance and consumer changes simultaneously. Rolling deployments and independent consumers make that assumption unsafe.

    Use compatible stages.

    2. Moving Code Without Moving Ownership

    If the new service still depends on direct writes to the monolith’s tables, the old coupling remains.

    Define a real system of record and remove cross-service writes.

    3. Running a Huge Backfill During Deployment

    Long data updates make deployment duration unpredictable and can overload the database.

    Use a separate resumable background process.

    4. Trusting Dual Writes Without Failure Analysis

    Two writes to two systems are not atomic. A success response from one does not guarantee the other succeeded.

    Use outbox, CDC or another design with explicit consistency guarantees.

    5. Assuming Messages Are Delivered Once and in Order

    Retries, rebalances and network failures can produce duplicates or reordering.

    Use stable IDs, idempotency, version checks and ordering only where the business requires it.

    6. Testing Only with Empty Databases

    Empty-database tests do not reveal lock duration, invalid historical data, volume, skew or backfill behaviour.

    Test upgrades using realistic data.

    7. Monitoring Only CPU and HTTP Errors

    Infrastructure can be healthy while orders, invoices or balances are wrong.

    Monitor business invariants and reconcile data.

    8. Keeping the Old Path Forever

    Temporary routes, flags and dual-read code create permanent complexity when no removal date exists.

    Set exit criteria and a cleanup owner before the migration begins.

    9. Deleting Old Data Too Early

    Early deletion eliminates recovery and comparison options.

    Retain data according to a documented recovery, compliance and cost decision—not an improvised cleanup.

    10. Calling a Backup a Rollback Plan

    A backup is useful only if it is complete, recent, restorable and compatible with the intended recovery point.

    Test restoration and measure how long it takes.


    Migration Readiness Checklist

    Planning

    • Business reason and scope are documented.
    • Current and future system of record are identified.
    • API, event, database, batch and reporting dependencies are inventoried.
    • Success, pause and abort thresholds are measurable.
    • Data volume and expected migration duration are estimated.
    • Security, privacy and retention requirements are reviewed.

    Design

    • Changes are backward-compatible during mixed-version operation.
    • Expand, migrate and contract releases are separated.
    • Large backfills are batched, resumable and idempotent.
    • The dual-write problem is addressed.
    • Consumers tolerate duplicates and required reordering scenarios.
    • API and event contracts have a compatibility strategy.
    • The authoritative owner is clear at every phase.

    Testing

    • Migration scripts run against production-like data volume.
    • Intermediate schema and application combinations are tested.
    • Contract tests cover known consumers.
    • Historical events can be replayed where required.
    • Backfill interruption and resumption are tested.
    • Reconciliation detects injected errors.
    • Rollback or roll-forward procedures are rehearsed.

    Deployment

    • Migration execution is controlled and locked.
    • Backups and restoration are verified.
    • Flags or routing controls are ready.
    • Dashboards and alerts exist before cutover.
    • Correlation IDs connect requests, events and logs.
    • Owners and escalation paths are available.

    Completion

    • Traffic is fully on the intended path.
    • Reconciliation meets the agreed threshold.
    • Old consumers and direct database readers are gone.
    • The confidence period has completed.
    • Obsolete schemas, routes, events and flags are removed.
    • Temporary credentials and infrastructure are revoked.
    • Documentation and incident lessons are updated.

    Frequently Asked Questions

    What is the safest migration strategy for microservices?

    For most live systems, the safest general approach is an incremental migration using expand-and-contract changes, gradual traffic shifting, continuous reconciliation and explicit recovery controls. The exact tools depend on the database, broker and deployment platform.

    What is an expand-and-contract migration?

    It divides a breaking change into compatible stages. First, expand the system by adding the new field, endpoint or schema. Next, migrate consumers and data. Finally, contract the system by removing the obsolete interface after it is no longer used.

    Can microservices share one database?

    They can share physical infrastructure, especially during transition, but each service should have clearly controlled schema and data ownership. Other services should not directly write the owner’s tables. Separate credentials and permissions help enforce the boundary.

    Should database migrations run when the application starts?

    Small systems sometimes use this approach, but it becomes risky when many replicas start simultaneously. A controlled, single migration job with locking, timeouts and recorded status is usually easier to reason about.

    How can a large table be migrated without downtime?

    Add compatible destination structures, copy data in resumable batches, capture ongoing changes, reconcile source and destination, shift traffic gradually, then remove the old structure only after the new path is stable.

    Is dual writing to old and new databases safe?

    Not automatically. One write can succeed while the other fails. If temporary duplication is required, define the authoritative source, failure behaviour, repair process and reconciliation. Prefer patterns such as transactional outbox and CDC where they fit.

    What is the difference between CDC and a transactional outbox?

    CDC captures committed changes from a database log. A transactional outbox deliberately stores domain-event records in an outbox table within the same transaction as the business update; a relay or CDC tool then publishes them. Raw CDC exposes storage-level changes, while an outbox can publish intentional domain contracts.

    How do you prevent duplicate event processing?

    Give each event a stable ID and make consumers idempotent. Record the processed ID atomically with the local business change, or design the state transition so repeating it has no additional effect.

    Should every breaking API change create a new version?

    Not every change is breaking. Prefer additive, backward-compatible evolution. Create a new version when compatibility cannot reasonably be preserved, then publish a deprecation plan and measure old-version usage until all consumers migrate.

    How should event schemas be versioned?

    Choose explicit backward, forward or full compatibility based on producer and consumer rollout order and replay requirements. Enforce the rule in CI or a schema registry, retain stable field meanings and test older consumers with newer schemas.

    What should be monitored during cutover?

    Monitor technical signals such as errors, latency, locks and event lag; migration signals such as traffic percentage, backfill progress and mismatch rate; and business signals such as completed orders, payments and monetary totals.

    Is rollback always possible?

    No. After new data formats, external actions or destructive changes occur, deploying the old code may be unsafe. Define rollback and roll-forward options for each phase and identify the point after which roll-forward is the approved response.

    When is a migration finished?

    It is finished when the new owner handles the intended traffic, data has been reconciled, old consumers have moved, the confidence period has passed and temporary code, access, infrastructure and old schemas have been safely removed.


    Final Summary

    Reliable microservice migration is a compatibility and data-correctness problem, not merely a deployment task.

    The most important practices are:

    • Discover real dependencies before changing ownership.
    • Define one authoritative owner at every stage.
    • Migrate incrementally instead of using a big-bang rewrite.
    • Use expand-and-contract changes for databases, APIs and events.
    • Keep large backfills separate, batched and resumable.
    • Use transactional outbox or CDC to avoid unsafe dual writes.
    • Make commands, consumers and migration jobs idempotent.
    • Reconcile data using business invariants, not only row counts.
    • Shift traffic gradually with measurable gates.
    • Design recovery before the first production change.
    • Remove temporary migration paths after a confidence period.

    The ideal migration may look slower on a project plan because it uses several releases. In production, those smaller reversible steps usually reduce risk, reveal problems earlier and allow normal product development to continue while the architecture evolves.

  • PHP Form Validation for Kids and Beginners — Part 10

    Web forms allow visitors to send information to a PHP program.

    However, we should never assume that submitted information is complete, correct or safe to display.

    A visitor might:

    • Leave a required box empty
    • Enter letters where a number is expected
    • Type an invalid email address
    • Select a value that our form does not allow
    • Enter a very long value by mistake
    • Change the form using browser developer tools
    • Send the request without using our webpage at all

    This is why PHP programs need form validation.

    In Part 10 of this PHP tutorial for kids and complete beginners, you will learn:

    • What form validation means
    • Why HTML validation is not enough by itself
    • How to check whether a form was submitted
    • How to safely read values from $_POST
    • How to check required fields
    • How to validate names, ages, email addresses and choices
    • How to show useful error messages
    • How to keep old values inside the form
    • How to escape information before displaying it
    • How to build a complete Kids’ Club registration form
    • How to organise repeated validation code with functions

    The examples use beginner-friendly PHP 8 syntax and do not require a database.


    Quick Answer

    PHP form validation checks whether submitted information follows the rules of our program.

    Here is a small example:

    <?php
    
    $name = "";
    $nameError = "";
    
    if ($_SERVER["REQUEST_METHOD"] === "POST") {
        $name = trim($_POST["name"] ?? "");
    
        if ($name === "") {
            $nameError = "Please enter your name.";
        }
    }
    
    ?>
    
    <form method="post">
        <label for="name">Name</label>
    
        <input
            type="text"
            id="name"
            name="name"
            value="<?php echo htmlspecialchars($name); ?>"
        >
    
        <p><?php echo $nameError; ?></p>
    
        <button type="submit">Submit</button>
    </form>

    The program:

    1. Waits for a POST request.
    2. Reads the submitted name.
    3. Removes unnecessary spaces with trim().
    4. Checks whether the name is empty.
    5. Displays an error if the visitor did not enter a name.
    6. Safely places the old value back inside the form.

    We will improve every part of this example during the tutorial.


    What Is Form Validation?

    Form validation means checking submitted information before using it.

    Imagine that we are creating a registration form with these rules:

    FieldRule
    NameRequired and no longer than 50 characters
    EmailRequired and must look like an email address
    AgeMust be a whole number from 7 to 17
    Favourite colourMust be one of the available choices
    AgreementMust be selected

    If the submitted data follows all the rules, it is valid.

    If one or more rules are broken, the program should not continue as though everything is correct. It should explain the problem and allow the visitor to fix it.

    Good validation should answer three questions:

    1. Is the value present?
    2. Is it the correct type or format?
    3. Is it acceptable for this particular program?

    An age of 500 is made from numbers, but it is not acceptable for a children’s club. Format checking alone is therefore not enough.


    Client-Side and Server-Side Validation

    HTML can perform some validation inside the browser.

    <input
        type="email"
        name="email"
        required
    >

    The required attribute asks the browser to prevent an empty submission. The email type asks the browser to check the basic email format.

    This is called client-side validation because it happens in the visitor’s browser.

    It is useful because the visitor receives quick feedback. However, it must not be our only validation.

    A visitor or another program can:

    • Remove the HTML attributes
    • Change the page using developer tools
    • Turn off browser validation
    • Send a request directly to the PHP file
    • Submit extra values that never appeared in the original form

    PHP validation runs on the server. This is called server-side validation.

    Validation typeRuns where?Main purpose
    HTML or JavaScriptVisitor’s browserFast and friendly feedback
    PHPWeb serverFinal trusted decision

    Use both when possible, but always let PHP make the final decision.


    A Basic HTML Form

    Create a new file named:

    registration.php

    Start with this form:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Kids' Club Registration</title>
    </head>
    <body>
    
        <h1>Kids' Club Registration</h1>
    
        <form method="post">
            <label for="name">Name</label>
            <input type="text" id="name" name="name">
    
            <label for="email">Email</label>
            <input type="email" id="email" name="email">
    
            <label for="age">Age</label>
            <input type="number" id="age" name="age">
    
            <button type="submit">Register</button>
        </form>
    
    </body>
    </html>

    The form uses:

    method="post"

    When the button is pressed, the browser sends the values to PHP in a POST request.

    Because the form has no action attribute, it submits to the same page.


    Checking Whether the Form Was Submitted

    The page is normally requested with GET when we first open it.

    The form sends a POST request when it is submitted.

    PHP provides the request method through:

    $_SERVER["REQUEST_METHOD"]

    We can check it like this:

    <?php
    
    if ($_SERVER["REQUEST_METHOD"] === "POST") {
        echo "The form was submitted.";
    }

    The strict comparison operator === checks both value and type.

    For this form, our validation code should run only after a POST request. Without the if statement, the page could display errors before the visitor has entered anything.


    Safely Reading Values from POST

    A submitted text field can be read from $_POST:

    $name = $_POST["name"];

    However, this assumes that name definitely exists.

    Someone can submit a request without that field. PHP may then show an “Undefined array key” warning.

    Use the null coalescing operator instead:

    $name = $_POST["name"] ?? "";

    This means:

    • Use $_POST["name"] if it exists.
    • Otherwise, use an empty string.

    For a simple beginner form, we can then remove spaces from the beginning and end:

    $name = trim($_POST["name"] ?? "");

    If a visitor enters:

       Aina   

    trim() changes it to:

    Aina

    A More Defensive Version

    A request can be changed so that name is sent as an array instead of text. Passing an array into trim() causes a type error.

    We can guard against that:

    $nameInput = $_POST["name"] ?? "";
    
    if (!is_string($nameInput)) {
        $nameInput = "";
    }
    
    $name = trim($nameInput);

    This is slightly longer, but it prevents malformed input from crashing the validation code.

    Later, we will place this repeated work inside a function.


    Checking a Required Field

    Create variables before processing the form:

    <?php
    
    $name = "";
    $nameError = "";
    
    if ($_SERVER["REQUEST_METHOD"] === "POST") {
        $nameInput = $_POST["name"] ?? "";
    
        if (!is_string($nameInput)) {
            $nameInput = "";
        }
    
        $name = trim($nameInput);
    
        if ($name === "") {
            $nameError = "Please enter your name.";
        }
    }

    We compare the cleaned name with an empty string:

    if ($name === "")

    If it is empty, we store an error message.

    We can display the message beneath the input:

    <label for="name">Name</label>
    
    <input type="text" id="name" name="name">
    
    <?php if ($nameError !== ""): ?>
        <p><?php echo $nameError; ?></p>
    <?php endif; ?>

    The paragraph appears only when an error exists.


    Checking the Length of a Name

    A required-field check prevents an empty value, but someone could still submit thousands of characters.

    We can add a maximum length:

    if ($name === "") {
        $nameError = "Please enter your name.";
    } elseif (strlen($name) > 50) {
        $nameError = "Your name must be 50 characters or fewer.";
    }

    The elseif runs only when the first condition is false.

    The browser can also receive the same limit:

    <input
        type="text"
        id="name"
        name="name"
        maxlength="50"
        required
    >

    Remember that the HTML rule improves the experience, while PHP still enforces the trusted rule.

    For multilingual production websites, developers often use mb_strlen() so that multibyte characters are counted properly. That function requires PHP’s mbstring extension. We use strlen() here to keep the first project simple.


    Validating an Email Address

    Checking whether an email contains @ is not enough.

    This is too weak:

    if (!str_contains($email, "@")) {
        $emailError = "Invalid email.";
    }

    PHP provides filter_var() with FILTER_VALIDATE_EMAIL:

    if ($email === "") {
        $emailError = "Please enter an email address.";
    } elseif (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
        $emailError = "Please enter a valid email address.";
    }

    Examples:

    ValueBasic result
    [email protected]Valid format
    aina.example.comInvalid format
    aina@Invalid format
    Empty stringRequired-field error

    Email validation checks the format. It does not prove that:

    • The mailbox exists
    • The address belongs to the visitor
    • The visitor can receive messages

    A real registration system normally sends a verification link or code to confirm ownership.


    Validating a Whole Number and Range

    Our club accepts ages from 7 to 17.

    We want to reject:

    • An empty value
    • Words such as twelve
    • Decimals such as 12.5
    • Ages lower than 7
    • Ages greater than 17

    PHP can validate the number and range together:

    $ageValue = filter_var(
        $age,
        FILTER_VALIDATE_INT,
        [
            "options" => [
                "min_range" => 7,
                "max_range" => 17
            ]
        ]
    );
    
    if ($age === "") {
        $ageError = "Please enter your age.";
    } elseif ($ageValue === false) {
        $ageError = "Age must be a whole number from 7 to 17.";
    }

    The strict check is important:

    $ageValue === false

    Some valid filters can return values that PHP might otherwise treat as false. A strict comparison makes our intention clear.

    The HTML input should also describe the range:

    <input
        type="number"
        id="age"
        name="age"
        min="7"
        max="17"
        step="1"
        required
    >

    Again, the PHP check remains necessary.


    Validating a Select Menu

    Suppose the form offers three favourite colours:

    <select id="colour" name="colour">
        <option value="">Choose a colour</option>
        <option value="red">Red</option>
        <option value="blue">Blue</option>
        <option value="green">Green</option>
    </select>

    It may appear impossible to submit another value, but a request can be edited.

    Create a list of allowed values:

    $allowedColours = [
        "red",
        "blue",
        "green"
    ];

    Then validate the submitted value:

    if (!in_array($colour, $allowedColours, true)) {
        $colourError = "Please choose one of the available colours.";
    }

    The final argument true tells in_array() to use strict comparison.

    This method is sometimes called an allowlist. Instead of trying to imagine every bad value, we accept only the values our program understands.


    Validating a Checkbox

    Checkboxes behave differently from text inputs.

    If a checkbox is not selected, its name is normally absent from $_POST.

    HTML:

    <input
        type="checkbox"
        id="agree"
        name="agree"
        value="yes"
    >
    
    <label for="agree">
        I agree to follow the club rules.
    </label>

    PHP:

    $agreed = (
        isset($_POST["agree"])
        && $_POST["agree"] === "yes"
    );
    
    if (!$agreed) {
        $agreeError = "You must agree to the club rules.";
    }

    We do not merely check whether any value was submitted. We check for the exact allowed value yes.


    Validation, Sanitisation and Escaping Are Different

    These words are related, but they do not mean the same thing.

    JobQuestionExample
    ValidationIs this value acceptable?Is age an integer from 7 to 17?
    SanitisationShould the value be transformed or cleaned?Remove surrounding spaces with trim()
    EscapingHow can this value be safely placed into a particular output?Convert special HTML characters before displaying text

    Consider this name:

    <script>alert('Hello')</script>

    If we directly place it into HTML, the browser may treat it as code.

    Do not output untrusted text like this:

    echo $_POST["name"];

    Escape it for HTML:

    echo htmlspecialchars(
        $name,
        ENT_QUOTES | ENT_SUBSTITUTE,
        "UTF-8"
    );

    The browser then displays the characters as text instead of interpreting them as an HTML tag.

    It is helpful to create a short function:

    function e(string $value): string
    {
        return htmlspecialchars(
            $value,
            ENT_QUOTES | ENT_SUBSTITUTE,
            "UTF-8"
        );
    }

    Now we can write:

    <?php echo e($name); ?>

    Escaping depends on where the value is going. HTML text, HTML attributes, URLs, JavaScript, SQL and terminal commands have different rules. htmlspecialchars() is for HTML output; it is not a database security function.


    Creating a Sticky Form

    A sticky form keeps valid submitted values after an error.

    Without a sticky form, a visitor who makes one mistake may need to type everything again.

    For a text input:

    <input
        type="text"
        id="name"
        name="name"
        value="<?php echo e($name); ?>"
    >

    For a select menu:

    <option
        value="blue"
        <?php echo $colour === "blue" ? "selected" : ""; ?>
    >
        Blue
    </option>

    For a checkbox:

    <input
        type="checkbox"
        id="agree"
        name="agree"
        value="yes"
        <?php echo $agreed ? "checked" : ""; ?>
    >

    Do not place raw submitted text into an HTML value attribute. Always escape it first.

    Passwords are a common exception: password boxes are normally cleared rather than refilled after an error.


    Storing Errors in an Array

    Separate variables such as $nameError and $emailError work, but a larger form becomes easier to manage with an array.

    $errors = [];

    Add an error using the field name as the key:

    if ($name === "") {
        $errors["name"] = "Please enter your name.";
    }

    Display one field’s error:

    <?php if (isset($errors["name"])): ?>
        <p class="error">
            <?php echo e($errors["name"]); ?>
        </p>
    <?php endif; ?>

    Check whether the entire form is valid:

    if ($errors === []) {
        $success = true;
    }

    An empty error array means that no validation rule failed.


    Reusing Code with a Helper Function

    Several text fields need the same safe reading steps.

    We can create a function:

    function postText(string $key): string
    {
        $value = $_POST[$key] ?? "";
    
        if (!is_string($value)) {
            return "";
        }
    
        return trim($value);
    }

    Now we can read values with:

    $name = postText("name");
    $email = postText("email");
    $age = postText("age");
    $colour = postText("colour");

    This makes the main validation section shorter and gives every text field consistent basic handling.

    The function does not decide whether a value is valid. It only returns a trimmed string or an empty string. Each field still has its own rules.


    Complete Project: Kids’ Club Registration Form

    The following project combines all the lessons into one file.

    Save it as:

    registration.php
    <?php
    
    function e(string $value): string
    {
        return htmlspecialchars(
            $value,
            ENT_QUOTES | ENT_SUBSTITUTE,
            "UTF-8"
        );
    }
    
    function postText(string $key): string
    {
        $value = $_POST[$key] ?? "";
    
        if (!is_string($value)) {
            return "";
        }
    
        return trim($value);
    }
    
    $name = "";
    $email = "";
    $age = "";
    $colour = "";
    $agreed = false;
    
    $errors = [];
    $success = false;
    
    $allowedColours = [
        "red",
        "blue",
        "green"
    ];
    
    if ($_SERVER["REQUEST_METHOD"] === "POST") {
        $name = postText("name");
        $email = postText("email");
        $age = postText("age");
        $colour = postText("colour");
    
        $agreed = (
            isset($_POST["agree"])
            && $_POST["agree"] === "yes"
        );
    
        if ($name === "") {
            $errors["name"] = "Please enter your name.";
        } elseif (strlen($name) > 50) {
            $errors["name"] = (
                "Your name must be 50 characters or fewer."
            );
        }
    
        if ($email === "") {
            $errors["email"] = (
                "Please enter an email address."
            );
        } elseif (
            filter_var(
                $email,
                FILTER_VALIDATE_EMAIL
            ) === false
        ) {
            $errors["email"] = (
                "Please enter a valid email address."
            );
        }
    
        if ($age === "") {
            $errors["age"] = "Please enter your age.";
        } else {
            $validAge = filter_var(
                $age,
                FILTER_VALIDATE_INT,
                [
                    "options" => [
                        "min_range" => 7,
                        "max_range" => 17
                    ]
                ]
            );
    
            if ($validAge === false) {
                $errors["age"] = (
                    "Age must be a whole number from 7 to 17."
                );
            }
        }
    
        if (!in_array($colour, $allowedColours, true)) {
            $errors["colour"] = (
                "Please choose an available colour."
            );
        }
    
        if (!$agreed) {
            $errors["agree"] = (
                "You must agree to follow the club rules."
            );
        }
    
        if ($errors === []) {
            $success = true;
        }
    }
    
    ?>
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Kids' Club Registration</title>
    
        <style>
            * {
                box-sizing: border-box;
            }
    
            body {
                margin: 0;
                padding: 30px 16px;
                background: #f2f6ff;
                color: #1f2937;
                font-family: Arial, sans-serif;
            }
    
            .card {
                width: 100%;
                max-width: 620px;
                margin: 0 auto;
                padding: 28px;
                border-radius: 16px;
                background: #ffffff;
                box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
            }
    
            h1 {
                margin-top: 0;
            }
    
            .field {
                margin-bottom: 20px;
            }
    
            label,
            legend {
                display: block;
                margin-bottom: 7px;
                font-weight: bold;
            }
    
            input[type="text"],
            input[type="email"],
            input[type="number"],
            select {
                width: 100%;
                padding: 11px;
                border: 1px solid #9ca3af;
                border-radius: 8px;
                font: inherit;
            }
    
            fieldset {
                padding: 0;
                border: 0;
            }
    
            .checkbox-row {
                display: flex;
                gap: 9px;
                align-items: flex-start;
            }
    
            .checkbox-row label {
                margin: 0;
                font-weight: normal;
            }
    
            .error {
                margin: 7px 0 0;
                color: #b91c1c;
                font-size: 0.92rem;
            }
    
            .error-summary {
                margin-bottom: 22px;
                padding: 14px;
                border: 1px solid #f87171;
                border-radius: 8px;
                background: #fef2f2;
            }
    
            .success {
                padding: 18px;
                border: 1px solid #34d399;
                border-radius: 8px;
                background: #ecfdf5;
            }
    
            button {
                padding: 11px 18px;
                border: 0;
                border-radius: 8px;
                background: #2563eb;
                color: white;
                font: inherit;
                font-weight: bold;
                cursor: pointer;
            }
    
            button:hover {
                background: #1d4ed8;
            }
        </style>
    </head>
    <body>
    
    <main class="card">
        <h1>Kids' Club Registration</h1>
    
        <?php if ($success): ?>
            <div class="success">
                <h2>Registration received!</h2>
    
                <p>
                    Welcome, <?php echo e($name); ?>.
                    Your form passed all the validation checks.
                </p>
    
                <p>
                    A real website could now save the information
                    or send a confirmation message.
                </p>
            </div>
        <?php else: ?>
    
            <?php if ($errors !== []): ?>
                <div class="error-summary">
                    <strong>Please correct the form.</strong>
    
                    <ul>
                        <?php foreach ($errors as $error): ?>
                            <li><?php echo e($error); ?></li>
                        <?php endforeach; ?>
                    </ul>
                </div>
            <?php endif; ?>
    
            <form method="post" novalidate>
                <div class="field">
                    <label for="name">Name</label>
    
                    <input
                        type="text"
                        id="name"
                        name="name"
                        maxlength="50"
                        value="<?php echo e($name); ?>"
                        required
                    >
    
                    <?php if (isset($errors["name"])): ?>
                        <p class="error">
                            <?php echo e($errors["name"]); ?>
                        </p>
                    <?php endif; ?>
                </div>
    
                <div class="field">
                    <label for="email">Email address</label>
    
                    <input
                        type="email"
                        id="email"
                        name="email"
                        value="<?php echo e($email); ?>"
                        required
                    >
    
                    <?php if (isset($errors["email"])): ?>
                        <p class="error">
                            <?php echo e($errors["email"]); ?>
                        </p>
                    <?php endif; ?>
                </div>
    
                <div class="field">
                    <label for="age">Age</label>
    
                    <input
                        type="number"
                        id="age"
                        name="age"
                        min="7"
                        max="17"
                        step="1"
                        value="<?php echo e($age); ?>"
                        required
                    >
    
                    <?php if (isset($errors["age"])): ?>
                        <p class="error">
                            <?php echo e($errors["age"]); ?>
                        </p>
                    <?php endif; ?>
                </div>
    
                <div class="field">
                    <label for="colour">Favourite colour</label>
    
                    <select id="colour" name="colour" required>
                        <option value="">Choose a colour</option>
    
                        <option
                            value="red"
                            <?php echo $colour === "red" ? "selected" : ""; ?>
                        >
                            Red
                        </option>
    
                        <option
                            value="blue"
                            <?php echo $colour === "blue" ? "selected" : ""; ?>
                        >
                            Blue
                        </option>
    
                        <option
                            value="green"
                            <?php echo $colour === "green" ? "selected" : ""; ?>
                        >
                            Green
                        </option>
                    </select>
    
                    <?php if (isset($errors["colour"])): ?>
                        <p class="error">
                            <?php echo e($errors["colour"]); ?>
                        </p>
                    <?php endif; ?>
                </div>
    
                <fieldset class="field">
                    <legend>Club rules</legend>
    
                    <div class="checkbox-row">
                        <input
                            type="checkbox"
                            id="agree"
                            name="agree"
                            value="yes"
                            <?php echo $agreed ? "checked" : ""; ?>
                        >
    
                        <label for="agree">
                            I agree to be kind and follow the club rules.
                        </label>
                    </div>
    
                    <?php if (isset($errors["agree"])): ?>
                        <p class="error">
                            <?php echo e($errors["agree"]); ?>
                        </p>
                    <?php endif; ?>
                </fieldset>
    
                <button type="submit">Register</button>
            </form>
        <?php endif; ?>
    </main>
    
    </body>
    </html>

    Why Does the Form Use novalidate?

    The project contains:

    <form method="post" novalidate>

    novalidate temporarily disables the browser’s automatic validation. This makes it easier to test and see our PHP error messages during the lesson.

    After you understand the PHP checks, remove novalidate:

    <form method="post">

    The final form will then use both browser validation and PHP validation.


    How the Complete Project Works

    1. Helper Functions Are Created

    The e() function escapes text for HTML:

    function e(string $value): string

    The postText() function safely reads a submitted string:

    function postText(string $key): string

    2. Default Values Are Prepared

    Before the form is submitted, the field values are empty and success is false:

    $name = "";
    $errors = [];
    $success = false;

    This also prevents undefined-variable warnings in the HTML section.

    3. PHP Waits for POST

    if ($_SERVER["REQUEST_METHOD"] === "POST")

    The validation runs only after submission.

    4. Every Field Is Checked

    Each field has rules that match its purpose:

    • Name: required and maximum length
    • Email: required and valid format
    • Age: required integer in a permitted range
    • Colour: must appear in the allowlist
    • Agreement: exact checkbox value required

    5. Success Requires No Errors

    if ($errors === []) {
        $success = true;
    }

    The program does not accept the form if even one error remains.

    6. The Page Shows One of Two Views

    If $success is true, the confirmation is shown.

    Otherwise, the form is shown with its previous values and any error messages.


    How to Run the Project

    Open a terminal in the folder containing registration.php.

    Run PHP’s built-in development server:

    php -S localhost:8000

    Open this address in your browser:

    http://localhost:8000/registration.php

    Stop the development server by pressing:

    Ctrl + C

    The built-in server is useful for learning and local development. It is not intended to be a public production web server.


    Testing the Validation

    Do not test only the happy path. Try values that should fail.

    TestExpected result
    Submit everything emptyRequired-field errors appear
    Name contains spaces onlyName error appears after trim()
    Name is longer than 50 charactersLength error appears
    Email is helloEmail-format error appears
    Age is 6Range error appears
    Age is 18Range error appears
    Age is 12.5Whole-number error appears
    No colour chosenColour error appears
    Agreement not selectedAgreement error appears
    One field is wrongOther valid values remain in the form
    Name contains <b>Aina</b>Tags appear as text, not formatted HTML
    Every field is validSuccess message appears

    You can also use browser developer tools to change a colour option to an unexpected value. PHP should reject it because it is not in $allowedColours.

    Testing invalid input is part of programming. It helps prove that the program follows its rules when users make mistakes or requests are modified.


    Common PHP Form Validation Mistakes

    1. Trusting required by Itself

    This improves browser validation:

    <input name="name" required>

    It does not replace PHP validation. A request can bypass the HTML page.

    2. Reading Missing Keys Directly

    Risky:

    $name = $_POST["name"];

    Safer:

    $name = $_POST["name"] ?? "";

    For defensive code, also confirm that the value is a string.

    3. Showing Submitted Data Without Escaping

    Unsafe:

    echo $_POST["name"];

    Safer for HTML output:

    echo htmlspecialchars(
        $name,
        ENT_QUOTES | ENT_SUBSTITUTE,
        "UTF-8"
    );

    4. Checking Only the Data Type

    This checks whether age is an integer:

    filter_var($age, FILTER_VALIDATE_INT)

    The program must still check whether the integer is in the allowed range.

    5. Trusting Select and Radio Values

    A menu is not a security boundary. Validate its value against an allowlist on the server.

    6. Using empty() Without Understanding It

    empty() treats several different values as empty, including the string "0".

    For required text, an explicit check is often clearer:

    if ($value === "")

    This is especially important if 0 might be a valid answer.

    7. Removing Characters Instead of Defining Rules

    Silently deleting unexpected characters may change a person’s real name or other important data.

    Whenever possible:

    1. Trim unnecessary surrounding spaces.
    2. Validate the value according to clear rules.
    3. Show an error if the value is unacceptable.
    4. Escape it for the output context when displayed.

    8. Confusing Email Format with Email Ownership

    FILTER_VALIDATE_EMAIL checks the format. Verification normally requires sending a link or code to the address.

    9. Saving Data Before All Checks Pass

    Do not write to a file or database and then discover that another field is invalid.

    The usual order is:

    1. Read the request.
    2. Validate all fields.
    3. If errors exist, show the form again.
    4. If there are no errors, perform the intended action.

    10. Believing Validation Solves Every Security Problem

    Validation is one part of secure form handling.

    A production form that changes data may also need:

    • CSRF protection
    • Authentication and authorisation
    • Safe database queries using prepared statements
    • Rate limiting
    • Secure file-upload rules
    • Password hashing
    • HTTPS
    • Server-side logging without exposing private information

    We will meet several of these ideas in later lessons.


    Validation Rules Should Match the Project

    There is no universal rule that every name must contain only letters.

    Real names can contain:

    • Spaces
    • Hyphens
    • Apostrophes
    • Accented letters
    • Characters from many writing systems

    A rule such as this can reject genuine names:

    if (!preg_match("/^[a-zA-Z]+$/", $name)) {
        // This is too restrictive for many real names.
    }

    For a beginner registration form, requiring a non-empty name and a reasonable maximum length is often more suitable.

    Validation is a program-design decision. Ask what the application truly needs rather than copying a strict rule from an unrelated tutorial.


    Practice Exercises

    Try these tasks before reading the answers.

    Exercise 1: Username

    Create a username field with these rules:

    • Required
    • At least 3 characters
    • No more than 20 characters

    Exercise 2: Lucky Number

    Create a lucky-number field that accepts only whole numbers from 1 to 100.

    Exercise 3: Favourite Animal

    Create a select menu containing:

    • Cat
    • Dog
    • Rabbit

    Validate the result with an allowlist.

    Exercise 4: Sticky Nickname

    Create a nickname input. If another field has an error, keep the submitted nickname inside the form and escape it safely.

    Exercise 5: Error List

    Store three errors inside an array and use foreach to display them as an HTML unordered list.

    Exercise 6: Add a Hobby

    Add a hobby field to the complete project. It should be optional but no longer than 100 characters.


    Exercise Answers

    Answer 1

    $username = postText("username");
    
    if ($username === "") {
        $errors["username"] = "Please enter a username.";
    } elseif (strlen($username) < 3) {
        $errors["username"] = (
            "The username must contain at least 3 characters."
        );
    } elseif (strlen($username) > 20) {
        $errors["username"] = (
            "The username must contain no more than 20 characters."
        );
    }

    Answer 2

    $luckyNumber = postText("lucky_number");
    
    $validLuckyNumber = filter_var(
        $luckyNumber,
        FILTER_VALIDATE_INT,
        [
            "options" => [
                "min_range" => 1,
                "max_range" => 100
            ]
        ]
    );
    
    if ($validLuckyNumber === false) {
        $errors["lucky_number"] = (
            "Enter a whole number from 1 to 100."
        );
    }

    Answer 3

    $animal = postText("animal");
    
    $allowedAnimals = [
        "cat",
        "dog",
        "rabbit"
    ];
    
    if (!in_array($animal, $allowedAnimals, true)) {
        $errors["animal"] = "Please choose a valid animal.";
    }

    Answer 4

    <input
        type="text"
        id="nickname"
        name="nickname"
        value="<?php echo e($nickname); ?>"
    >

    The value must be escaped before it is placed inside the HTML attribute.

    Answer 5

    $errors = [
        "Please enter your name.",
        "Please enter a valid email address.",
        "Please choose a colour."
    ];
    <ul>
        <?php foreach ($errors as $error): ?>
            <li><?php echo e($error); ?></li>
        <?php endforeach; ?>
    </ul>

    Answer 6

    $hobby = postText("hobby");
    
    if (strlen($hobby) > 100) {
        $errors["hobby"] = (
            "Your hobby must be 100 characters or fewer."
        );
    }

    There is no required-field check because an empty hobby is allowed.


    PHP Form Validation Cheat Sheet

    Check for POST

    if ($_SERVER["REQUEST_METHOD"] === "POST") {
        // Validate submitted values.
    }

    Read a Value with a Default

    $name = $_POST["name"] ?? "";

    Trim a String Safely

    $value = $_POST["field"] ?? "";
    
    if (!is_string($value)) {
        $value = "";
    }
    
    $value = trim($value);

    Check Required Text

    if ($value === "") {
        $errors["field"] = "This field is required.";
    }

    Check Maximum Length

    if (strlen($value) > 50) {
        $errors["field"] = "Use 50 characters or fewer.";
    }

    Validate an Email

    if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
        $errors["email"] = "Enter a valid email address.";
    }

    Validate an Integer Range

    $number = filter_var(
        $value,
        FILTER_VALIDATE_INT,
        [
            "options" => [
                "min_range" => 1,
                "max_range" => 100
            ]
        ]
    );
    
    if ($number === false) {
        $errors["number"] = "Enter a whole number from 1 to 100.";
    }

    Validate an Allowed Choice

    if (!in_array($choice, $allowedChoices, true)) {
        $errors["choice"] = "Choose an available option.";
    }

    Escape HTML Output

    echo htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        "UTF-8"
    );

    Check Whether Validation Passed

    if ($errors === []) {
        echo "The form is valid.";
    }

    Frequently Asked Questions

    What is PHP form validation?

    PHP form validation checks submitted values on the server before the program accepts or uses them. It can check required fields, formats, lengths, ranges and allowed choices.

    Why is HTML required not enough?

    HTML validation happens in the browser and can be changed or bypassed. PHP must repeat the important checks on the server.

    What does trim() do?

    trim() removes whitespace from the beginning and end of a string. It is useful when a visitor accidentally enters extra spaces.

    What does the double question mark mean?

    The null coalescing operator ?? provides a default value when an array key does not exist or contains null.

    $name = $_POST["name"] ?? "";

    Should I use GET or POST for a registration form?

    Use POST for a form that submits registration information or causes a change. GET is more suitable for retrieval operations such as searches and filters whose parameters can appear in the URL.

    POST values not appearing in the address bar does not automatically make them encrypted. Use HTTPS for information travelling between the browser and server.

    Does filter_var() clean all dangerous input?

    No. filter_var() performs the filter you specifically request. For example, FILTER_VALIDATE_EMAIL checks email format. It does not automatically make the value safe for HTML, SQL or every other use.

    Why use htmlspecialchars()?

    It converts special characters so the browser treats them as text in HTML output rather than interpreting them as markup. Use it when displaying untrusted text in HTML.

    Is validation the same as escaping?

    No. Validation decides whether a value follows the program’s rules. Escaping safely represents a value in a particular output context. A valid name must still be escaped before being inserted into HTML.

    What is a sticky form?

    A sticky form keeps previously submitted values after validation fails. It prevents visitors from having to re-enter every correct field.

    Why validate a select menu?

    A visitor can modify HTML or send a request directly. PHP should accept only values that the server recognises.

    Should error messages explain the exact problem?

    For normal validation errors, yes. “Age must be a whole number from 7 to 17” is more useful than “Invalid input.” Security-sensitive systems may use more general messages for information such as login failures.

    Can I save the successful form to a database now?

    You can after learning safe database access. Use PDO prepared statements, validate the data first, and never build SQL by joining raw submitted values into a query.

    What is CSRF protection?

    Cross-site request forgery protection helps prove that a state-changing form was intentionally submitted from your application. It normally uses a random token stored in a server-side session. It becomes important when forms create, update or delete real data.

    Why does the example not save anything?

    Part 10 focuses on validation. Keeping the first project independent of a database makes it easier to see the complete request, validation and response process.


    Final Summary

    Form validation protects the rules and reliability of a PHP application.

    In this tutorial, we learned:

    • The browser can help with validation, but PHP makes the final decision.
    • $_SERVER["REQUEST_METHOD"] tells us how the page was requested.
    • The ?? operator prevents warnings when a submitted key is missing.
    • is_string() can reject malformed array input before string functions are used.
    • trim() removes unnecessary surrounding spaces.
    • Required fields can be checked against an empty string.
    • Length rules prevent unexpectedly large values.
    • filter_var() can validate email addresses and integers.
    • Number fields often need both a type check and a range check.
    • Select, radio and checkbox values must also be checked on the server.
    • An allowlist accepts only choices our program understands.
    • Validation, sanitisation and output escaping have different purposes.
    • htmlspecialchars() safely represents untrusted text in HTML.
    • Sticky forms improve the experience after an error.
    • An error array makes larger forms easier to organise.
    • A form should continue only when every important check passes.

    The best way to learn validation is to test both correct and incorrect values. Try missing fields, wrong formats, unexpected choices and very long text, then confirm that the program responds clearly without crashing.

    In Part 11, we can learn how PHP sessions and cookies remember information between different page requests. We can then use a session to add a CSRF token and create safer multi-page projects.

  • AI-Powered Image Captioning System with PHP: Complete Beginner Tutorial

    Image captioning allows a computer to examine a picture and produce a written description of what it sees.

    Instead of manually writing captions for every uploaded image, we can build a PHP application that sends the image to an AI vision model and receives a useful caption in return.

    In this tutorial, we will build a complete AI-powered image captioning system using PHP, HTML, CSS, JavaScript and the OpenAI Responses API.

    The finished application will allow a user to:

    • Select a JPG, PNG or WebP image
    • Preview the image before submitting it
    • Choose a caption style
    • Upload the image securely to PHP
    • Send the image to an AI model
    • Display the generated caption
    • Copy the caption with one click
    • Handle invalid files and API errors safely

    The project uses one PHP file, so it is suitable for beginners and can run on XAMPP, Laragon, MAMP or a normal PHP web server.

    Important: This project uses a paid API. API usage is separate from a ChatGPT subscription. Check the current model availability and pricing before deploying it publicly.


    Quick Answer

    The basic process is:

    1. The browser sends an image to PHP.
    2. PHP checks the file size and MIME type.
    3. PHP converts the image into a Base64 data URL.
    4. PHP sends the image and caption instructions to a vision-capable AI model.
    5. The model returns text describing the image.
    6. PHP safely displays the caption in the browser.

    The central API request looks like this:

    $payload = [
        "model" => "gpt-5-nano",
        "input" => [
            [
                "role" => "user",
                "content" => [
                    [
                        "type" => "input_text",
                        "text" => $prompt
                    ],
                    [
                        "type" => "input_image",
                        "image_url" => $imageDataUrl,
                        "detail" => "auto"
                    ]
                ]
            ]
        ],
        "max_output_tokens" => 300,
        "store" => false
    ];

    OpenAI’s current image-input guide confirms that the Responses API accepts images as fully qualified URLs, file IDs or Base64 data URLs. The model used in this tutorial accepts image input and returns text. See the official images and vision guide and GPT-5 nano model page.


    What Is AI Image Captioning?

    AI image captioning combines computer vision and natural-language generation.

    Computer vision helps the model examine visual information such as:

    • Objects
    • Colours
    • People
    • Animals
    • Buildings
    • Text visible in the image
    • Actions
    • Relationships between objects
    • The general setting

    The language-generation part turns those observations into readable text.

    For example, an uploaded picture might produce:

    A brown dog runs across a grassy field while carrying a red ball.

    The same image could receive different captions depending on the instruction:

    Caption typePossible result
    Short captionA dog carrying a red ball in a field.
    Social-media captionChasing sunshine, fresh air and one very important red ball.
    Alt textBrown dog running on grass with a red ball in its mouth.
    Detailed descriptionA medium-sized brown dog runs from left to right across a green field while holding a red rubber ball.

    This is why the prompt matters. We are not only sending an image; we are also telling the model what kind of description to produce.


    Where Can Image Captioning Be Used?

    An image-captioning system can be used in:

    • Blogging platforms
    • Product catalogues
    • Social-media tools
    • Photo-management applications
    • Content-management systems
    • Accessibility workflows
    • News and media websites
    • Real-estate listings
    • Travel websites
    • Classroom projects
    • Internal document systems

    For example, a travel blogger could upload a photograph of Fushimi Inari Shrine and ask the application to suggest a short caption. An online shop could request a draft product description. A content editor could generate a first version of image alt text and then review it before publishing.

    AI output should be treated as a draft. A model can overlook an object, misread visible text or describe something incorrectly. Human review remains important, especially for accessibility, journalism, medical images, identity-related claims and product information.


    How the PHP Application Works

    StageWhat happens
    1. SelectThe user chooses an image in the browser.
    2. PreviewJavaScript displays a local preview.
    3. UploadThe browser sends the form using multipart/form-data.
    4. ValidatePHP checks the upload error, size and actual MIME type.
    5. EncodePHP converts the image bytes into a Base64 data URL.
    6. RequestPHP sends the prompt and image to the Responses API.
    7. ExtractPHP finds the returned output_text.
    8. DisplayThe caption is escaped and shown to the user.

    The API key stays on the server. It is never placed inside JavaScript or sent to the user’s browser.


    Project Requirements

    You will need:

    • PHP 8.1 or newer
    • The PHP cURL extension
    • The PHP Fileinfo extension
    • A web server or PHP’s built-in development server
    • An OpenAI API key
    • An API account with billing or available credits
    • A code editor such as Visual Studio Code

    Check your PHP version:

    php -v

    Check whether cURL and Fileinfo are enabled:

    php -m

    Look for:

    curl
    fileinfo

    If you are using XAMPP or Laragon, these extensions are commonly included, although cURL may need to be enabled in php.ini.


    Step 1: Create the Project Folder

    Create a folder named:

    php-image-captioner

    Inside it, create:

    index.php

    The project begins with only one file:

    php-image-captioner/
    └── index.php

    Keeping the first version in one file makes the request flow easier to understand. Later, the CSS, JavaScript and API code can be separated into their own files or classes.


    Step 2: Create and Protect the API Key

    Create an API key from your API account.

    Do not write the key directly into index.php, commit it to Git or expose it in browser-side JavaScript.

    Use an environment variable named:

    OPENAI_API_KEY

    Linux or macOS

    For the current terminal session:

    export OPENAI_API_KEY="your_api_key_here"

    Then start the PHP server from the same terminal.

    Windows PowerShell

    For the current PowerShell session:

    $env:OPENAI_API_KEY="your_api_key_here"

    Apache

    Production servers should inject the secret through their deployment system, hosting control panel, secret manager or server environment. Avoid placing secrets in a publicly accessible .env file.

    PHP will read the value with:

    $apiKey = getenv("OPENAI_API_KEY");

    If an API key has ever appeared in a public repository or webpage, revoke it and create a new one. Removing the visible text from the latest commit is not enough because the secret may remain in Git history.


    Step 3: Add the Complete PHP Application

    Copy the following code into index.php:

    <?php
    
    declare(strict_types=1);
    
    session_start();
    
    const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
    
    $allowedMimeTypes = [
        "image/jpeg",
        "image/png",
        "image/webp"
    ];
    
    $captionStyles = [
        "short" => [
            "label" => "Short caption",
            "prompt" => "Write one clear factual sentence describing this image. "
                . "Keep it below 25 words. Do not start with 'This image shows'."
        ],
        "social" => [
            "label" => "Social-media caption",
            "prompt" => "Write an engaging but truthful social-media caption for this image. "
                . "Use no more than 35 words and at most two suitable emojis. "
                . "Do not invent names, places or events that cannot be confirmed visually."
        ],
        "alt" => [
            "label" => "Accessible alt text",
            "prompt" => "Write concise alt text for this image. Describe the most important "
                . "visible content and function in no more than 125 characters. "
                . "Do not begin with 'image of' or 'picture of'. "
                . "Do not guess identity or sensitive personal attributes."
        ],
        "detailed" => [
            "label" => "Detailed description",
            "prompt" => "Describe this image accurately in two or three sentences. "
                . "Mention the main subjects, visible actions, setting and important details. "
                . "Clearly express uncertainty instead of guessing."
        ]
    ];
    
    $caption = null;
    $error = null;
    $selectedStyle = $_POST["caption_style"] ?? "short";
    
    if (
        !is_string($selectedStyle)
        || !isset($captionStyles[$selectedStyle])
    ) {
        $selectedStyle = "short";
    }
    
    if (empty($_SESSION["csrf_token"])) {
        $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
    }
    
    function extractOutputText(array $response): ?string
    {
        $parts = [];
    
        foreach ($response["output"] ?? [] as $outputItem) {
            foreach ($outputItem["content"] ?? [] as $contentItem) {
                if (
                    ($contentItem["type"] ?? "") === "output_text"
                    && isset($contentItem["text"])
                ) {
                    $parts[] = trim((string) $contentItem["text"]);
                }
            }
        }
    
        $text = trim(implode("\n", array_filter($parts)));
    
        return $text !== "" ? $text : null;
    }
    
    if ($_SERVER["REQUEST_METHOD"] === "POST") {
        $submittedToken = $_POST["csrf_token"] ?? "";
    
        if (
            !is_string($submittedToken)
            || !hash_equals($_SESSION["csrf_token"], $submittedToken)
        ) {
            $error = "The form session has expired. Refresh the page and try again.";
        } elseif (!isset($_FILES["image"])) {
            $error = "Please choose an image.";
        } elseif ($_FILES["image"]["error"] !== UPLOAD_ERR_OK) {
            $error = "The image could not be uploaded. Please try another file.";
        } elseif ($_FILES["image"]["size"] > MAX_IMAGE_SIZE) {
            $error = "The image is too large. The maximum size is 5 MB.";
        } else {
            $temporaryPath = $_FILES["image"]["tmp_name"];
            $fileInfo = new finfo(FILEINFO_MIME_TYPE);
            $mimeType = $fileInfo->file($temporaryPath);
    
            if (!in_array($mimeType, $allowedMimeTypes, true)) {
                $error = "Only JPG, PNG and WebP images are allowed.";
            } else {
                $imageBytes = file_get_contents($temporaryPath);
    
                if ($imageBytes === false) {
                    $error = "PHP could not read the uploaded image.";
                } else {
                    $apiKey = getenv("OPENAI_API_KEY");
    
                    if (!$apiKey) {
                        $error = "The server is missing its API configuration.";
                    } else {
                        $imageDataUrl = "data:"
                            . $mimeType
                            . ";base64,"
                            . base64_encode($imageBytes);
    
                        $payload = [
                            "model" => "gpt-5-nano",
                            "input" => [
                                [
                                    "role" => "user",
                                    "content" => [
                                        [
                                            "type" => "input_text",
                                            "text" => $captionStyles[$selectedStyle]["prompt"]
                                        ],
                                        [
                                            "type" => "input_image",
                                            "image_url" => $imageDataUrl,
                                            "detail" => "auto"
                                        ]
                                    ]
                                ]
                            ],
                            "max_output_tokens" => 300,
                            "store" => false
                        ];
    
                        $curl = curl_init("https://api.openai.com/v1/responses");
    
                        curl_setopt_array($curl, [
                            CURLOPT_POST => true,
                            CURLOPT_RETURNTRANSFER => true,
                            CURLOPT_CONNECTTIMEOUT => 10,
                            CURLOPT_TIMEOUT => 60,
                            CURLOPT_HTTPHEADER => [
                                "Authorization: Bearer " . $apiKey,
                                "Content-Type: application/json"
                            ],
                            CURLOPT_POSTFIELDS => json_encode(
                                $payload,
                                JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
                            )
                        ]);
    
                        $responseBody = curl_exec($curl);
                        $curlError = curl_error($curl);
                        $statusCode = (int) curl_getinfo(
                            $curl,
                            CURLINFO_HTTP_CODE
                        );
    
                        curl_close($curl);
    
                        if ($responseBody === false) {
                            $error = "The caption service could not be reached: "
                                . $curlError;
                        } else {
                            try {
                                $responseData = json_decode(
                                    $responseBody,
                                    true,
                                    512,
                                    JSON_THROW_ON_ERROR
                                );
    
                                if ($statusCode < 200 || $statusCode >= 300) {
                                    $apiMessage = $responseData["error"]["message"]
                                        ?? "The API rejected the request.";
    
                                    $error = "Caption generation failed: "
                                        . $apiMessage;
                                } else {
                                    $caption = extractOutputText($responseData);
    
                                    if ($caption === null) {
                                        $error = "The API returned no caption. Please try again.";
                                    }
                                }
                            } catch (JsonException $exception) {
                                $error = "The caption service returned an invalid response.";
                            }
                        }
                    }
                }
            }
        }
    }
    
    ?>
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>AI Image Caption Generator</title>
    
        <style>
            :root {
                color-scheme: light;
                --background: #f4f7fb;
                --card: #ffffff;
                --text: #172033;
                --muted: #667085;
                --primary: #5b4cf0;
                --primary-dark: #4338ca;
                --border: #dce2eb;
                --success: #ecfdf3;
                --success-border: #86efac;
                --danger: #fff1f2;
                --danger-border: #fda4af;
            }
    
            * {
                box-sizing: border-box;
            }
    
            body {
                margin: 0;
                min-height: 100vh;
                padding: 40px 18px;
                font-family: Arial, Helvetica, sans-serif;
                color: var(--text);
                background: linear-gradient(135deg, #eef2ff, var(--background));
            }
    
            .app {
                width: min(760px, 100%);
                margin: 0 auto;
                padding: 32px;
                border: 1px solid rgba(255, 255, 255, 0.8);
                border-radius: 22px;
                background: var(--card);
                box-shadow: 0 20px 60px rgba(23, 32, 51, 0.12);
            }
    
            h1 {
                margin: 0 0 10px;
                font-size: clamp(1.8rem, 5vw, 2.5rem);
            }
    
            .intro {
                margin: 0 0 28px;
                color: var(--muted);
                line-height: 1.6;
            }
    
            label {
                display: block;
                margin-bottom: 8px;
                font-weight: 700;
            }
    
            input[type="file"],
            select {
                width: 100%;
                margin-bottom: 20px;
                padding: 12px;
                border: 1px solid var(--border);
                border-radius: 10px;
                background: #fff;
                font: inherit;
            }
    
            input[type="file"]::file-selector-button {
                margin-right: 12px;
                padding: 9px 14px;
                border: 0;
                border-radius: 8px;
                color: #fff;
                background: var(--primary);
                cursor: pointer;
            }
    
            .preview {
                display: none;
                width: 100%;
                max-height: 380px;
                margin: 0 0 22px;
                border-radius: 14px;
                object-fit: contain;
                background: #f8fafc;
            }
    
            .generate-button,
            .copy-button {
                border: 0;
                border-radius: 10px;
                color: #fff;
                background: var(--primary);
                font: inherit;
                font-weight: 700;
                cursor: pointer;
            }
    
            .generate-button {
                width: 100%;
                padding: 14px 18px;
            }
    
            .generate-button:hover,
            .copy-button:hover {
                background: var(--primary-dark);
            }
    
            .generate-button:disabled {
                cursor: wait;
                opacity: 0.7;
            }
    
            .message,
            .result {
                margin-top: 24px;
                padding: 18px;
                border: 1px solid;
                border-radius: 12px;
                line-height: 1.6;
            }
    
            .message {
                border-color: var(--danger-border);
                background: var(--danger);
            }
    
            .result {
                border-color: var(--success-border);
                background: var(--success);
            }
    
            .result h2 {
                margin: 0 0 8px;
                font-size: 1.1rem;
            }
    
            .caption-text {
                margin: 0 0 14px;
                white-space: pre-wrap;
            }
    
            .copy-button {
                padding: 9px 14px;
            }
    
            .help {
                margin-top: -12px;
                margin-bottom: 20px;
                color: var(--muted);
                font-size: 0.9rem;
            }
    
            @media (max-width: 600px) {
                body {
                    padding: 18px 12px;
                }
    
                .app {
                    padding: 22px;
                    border-radius: 16px;
                }
            }
        </style>
    </head>
    <body>
    
    <main class="app">
        <h1>AI Image Caption Generator</h1>
    
        <p class="intro">
            Upload an image and choose how you want the AI to describe it.
        </p>
    
        <form method="post" enctype="multipart/form-data" id="caption-form">
            <input
                type="hidden"
                name="csrf_token"
                value="<?php echo htmlspecialchars(
                    $_SESSION["csrf_token"],
                    ENT_QUOTES,
                    "UTF-8"
                ); ?>"
            >
    
            <label for="image">Choose an image</label>
            <input
                type="file"
                name="image"
                id="image"
                accept="image/jpeg,image/png,image/webp"
                required
            >
    
            <p class="help">JPG, PNG or WebP. Maximum file size: 5 MB.</p>
    
            <img id="preview" class="preview" alt="Selected image preview">
    
            <label for="caption_style">Caption style</label>
            <select name="caption_style" id="caption_style">
                <?php foreach ($captionStyles as $value => $style): ?>
                    <option
                        value="<?php echo htmlspecialchars($value); ?>"
                        <?php echo $selectedStyle === $value ? "selected" : ""; ?>
                    >
                        <?php echo htmlspecialchars($style["label"]); ?>
                    </option>
                <?php endforeach; ?>
            </select>
    
            <button type="submit" class="generate-button" id="generate-button">
                Generate caption
            </button>
        </form>
    
        <?php if ($error !== null): ?>
            <div class="message" role="alert">
                <?php echo htmlspecialchars($error, ENT_QUOTES, "UTF-8"); ?>
            </div>
        <?php endif; ?>
    
        <?php if ($caption !== null): ?>
            <section class="result" aria-live="polite">
                <h2>Generated caption</h2>
    
                <p class="caption-text" id="caption-text"><?php
                    echo htmlspecialchars($caption, ENT_QUOTES, "UTF-8");
                ?></p>
    
                <button type="button" class="copy-button" id="copy-button">
                    Copy caption
                </button>
            </section>
        <?php endif; ?>
    </main>
    
    <script>
        const imageInput = document.getElementById("image");
        const preview = document.getElementById("preview");
        const form = document.getElementById("caption-form");
        const generateButton = document.getElementById("generate-button");
        const copyButton = document.getElementById("copy-button");
    
        imageInput.addEventListener("change", () => {
            const file = imageInput.files[0];
    
            if (!file) {
                preview.removeAttribute("src");
                preview.style.display = "none";
                return;
            }
    
            preview.src = URL.createObjectURL(file);
            preview.style.display = "block";
        });
    
        form.addEventListener("submit", () => {
            generateButton.disabled = true;
            generateButton.textContent = "Generating caption...";
        });
    
        if (copyButton) {
            copyButton.addEventListener("click", async () => {
                const captionText = document
                    .getElementById("caption-text")
                    .innerText;
    
                try {
                    await navigator.clipboard.writeText(captionText);
                    copyButton.textContent = "Copied!";
    
                    setTimeout(() => {
                        copyButton.textContent = "Copy caption";
                    }, 1500);
                } catch (error) {
                    copyButton.textContent = "Copy failed";
                }
            });
        }
    </script>
    
    </body>
    </html>

    Step 4: Run the Project

    Open a terminal inside the project folder.

    Make sure the OPENAI_API_KEY environment variable is available, then run:

    php -S localhost:8000

    Open this address in your browser:

    http://localhost:8000

    Select an image, choose a caption style and click Generate caption.

    The first request may take several seconds because the image must be uploaded, encoded, processed and returned.


    Understanding the Important PHP Code

    The full example is long because it includes validation, error handling, styling and JavaScript. The essential PHP sections are easier to understand separately.

    1. Start a Session

    session_start();

    The session stores the CSRF token used to confirm that the submitted form originated from the application.

    2. Limit the File Size

    const MAX_IMAGE_SIZE = 5 * 1024 * 1024;

    This creates a five-megabyte application limit.

    PHP and the web server can have their own upload limits. If PHP rejects a large upload before the script runs, inspect these php.ini settings:

    upload_max_filesize = 6M
    post_max_size = 7M

    post_max_size should be slightly larger than upload_max_filesize because the full form request contains more than the image bytes.

    Restart the web server after modifying php.ini.

    3. Check the Actual MIME Type

    $fileInfo = new finfo(FILEINFO_MIME_TYPE);
    $mimeType = $fileInfo->file($temporaryPath);

    Do not rely only on:

    $_FILES["image"]["type"]

    That value comes from the browser and can be misleading. Fileinfo examines the uploaded file on the server.

    Checking the filename extension alone is also insufficient. A dangerous or unrelated file can be renamed to end with .jpg.

    4. Convert the Image to Base64

    $imageDataUrl = "data:"
        . $mimeType
        . ";base64,"
        . base64_encode($imageBytes);

    This creates a data URL similar to:

    data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...

    Base64 makes it possible to include the image inside the JSON request. It increases the data size, so the tutorial limits uploads to 5 MB.

    For larger production workflows, uploading once and referencing a file ID can be more efficient than repeatedly embedding the same image.

    5. Send Text and Image Input Together

    "content" => [
        [
            "type" => "input_text",
            "text" => $captionStyles[$selectedStyle]["prompt"]
        ],
        [
            "type" => "input_image",
            "image_url" => $imageDataUrl,
            "detail" => "auto"
        ]
    ]

    The model receives two related inputs:

    • The written instruction
    • The image to examine

    The detail setting controls how the image is processed. auto lets the API select an appropriate level. A low-detail setting may reduce input usage for simple images, while high detail may be more suitable when small objects or text matter. Confirm supported values and current image-token behaviour in the official image-input documentation.

    6. Keep the Key on the Server

    "Authorization: Bearer " . $apiKey

    The authorization header is created by PHP. The browser never sees the API key.

    Never make a direct API request from public frontend JavaScript using a permanent secret key. Anyone who can inspect the page or network request could steal it and use your account.

    7. Decode the Response

    $responseData = json_decode(
        $responseBody,
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    The JSON_THROW_ON_ERROR option causes PHP to throw a JsonException if the response is invalid JSON. The application catches the exception and shows a controlled error.

    8. Extract output_text

    The raw Responses API result contains an output array. Each output item can contain one or more content items.

    The helper function searches them:

    function extractOutputText(array $response): ?string
    {
        $parts = [];
    
        foreach ($response["output"] ?? [] as $outputItem) {
            foreach ($outputItem["content"] ?? [] as $contentItem) {
                if (
                    ($contentItem["type"] ?? "") === "output_text"
                    && isset($contentItem["text"])
                ) {
                    $parts[] = trim((string) $contentItem["text"]);
                }
            }
        }
    
        $text = trim(implode("\n", array_filter($parts)));
    
        return $text !== "" ? $text : null;
    }

    This is safer than assuming the caption is always at one fixed numeric position.

    9. Escape the Caption Before Displaying It

    echo htmlspecialchars(
        $caption,
        ENT_QUOTES,
        "UTF-8"
    );

    AI output should be treated as untrusted text. htmlspecialchars() prevents returned HTML-like content from becoming executable webpage markup.


    Why Use Different Caption Styles?

    One description does not suit every job.

    Short Caption

    Useful beneath photographs in articles or galleries.

    Prompt goal:

    Write one clear factual sentence below 25 words.

    Social-Media Caption

    Allows slightly more personality while still instructing the model not to invent unsupported facts.

    The model should not claim that a picture was taken in Tokyo merely because the street looks Japanese. It should not invent an event name, person’s identity or exact date.

    Accessible Alt Text

    Alt text communicates an image’s important content or function to people who cannot see it.

    Good alt text depends on context. The same image may need different alt text on different pages. A decorative image may need empty alt text instead of an AI description.

    Therefore, AI can suggest alt text, but the website author should decide whether the result is appropriate.

    Detailed Description

    Useful when the user needs more context than a short caption provides. It can mention the setting, positions, visible actions and important objects.


    Choosing a Model

    This tutorial uses:

    "model" => "gpt-5-nano"

    According to the current official model page, GPT-5 nano accepts text and image input and produces text output. It is positioned as the fastest, cheapest GPT-5 model, making it a practical starting point for a small captioning demo.

    If its captions are not accurate enough for your images, replace the model ID with another vision-capable model available to your API project. A stronger model can improve difficult scene understanding, small-text recognition and instruction following, but it will usually cost more and may respond more slowly.

    Model names, availability and pricing can change. Check the official OpenAI model guide before launching a production system.

    Do not silently change models in a live application. Test the new model using a fixed set of representative images and compare:

    • Factual accuracy
    • Missing important details
    • Invented details
    • Caption length
    • Latency
    • Cost per request
    • Performance on text-heavy images
    • Performance on people, products and unusual scenes

    Improving Caption Accuracy

    Give a Specific Instruction

    Weak prompt:

    Describe this.

    Better prompt:

    Write one factual caption below 25 words. Mention the main subject and visible action. Do not guess the place, identity or event.

    Specific requirements help the model understand what the application needs.

    Tell the Model What Not to Guess

    Image models can make plausible but unsupported assumptions.

    Useful restrictions include:

    • Do not identify unknown people.
    • Do not infer ethnicity, religion, health or other sensitive traits.
    • Do not invent a location.
    • Do not assume the occasion.
    • Do not claim uncertain text is readable.
    • Express uncertainty when an object is unclear.

    Preserve Enough Image Quality

    Very small, blurred or heavily compressed images are difficult to understand. If important text occupies only a tiny portion of the image, crop the relevant area or use a suitable detail setting.

    Test with Real Images

    Do not test only with clear stock photography. Include examples that match the intended application:

    • Dark images
    • Crowded scenes
    • Screenshots
    • Products on similar backgrounds
    • Images containing text
    • Portrait and landscape orientations
    • Mobile photographs
    • Illustrations
    • Partly obstructed subjects

    Security Considerations

    A demonstration running on a private computer is different from a public upload service. Before allowing unknown visitors to use the application, strengthen it.

    1. Add Rate Limiting

    Without rate limiting, one visitor or bot could send many requests and consume the API budget.

    Possible limits include:

    • Requests per IP address
    • Requests per signed-in account
    • Daily account allowance
    • Maximum concurrent requests
    • Monthly spending alerts
    • A hard application budget

    A session-based counter alone is not strong protection because users can start new sessions.

    2. Require Authentication

    If the tool is intended for staff or registered users, require login before permitting API requests.

    3. Validate on the Server

    The HTML accept attribute improves the file picker, but it is not a security control. An attacker can send a custom HTTP request.

    Always enforce the file type and size in PHP.

    4. Do Not Trust Original Filenames

    The tutorial does not save the file permanently. If you add storage, generate a random server-side filename instead of using the uploaded name.

    Store uploads outside the public web root where possible. If they must be public, configure the upload directory so PHP scripts cannot execute there.

    5. Protect Secrets

    • Keep the API key in a protected server environment.
    • Never log the complete authorization header.
    • Never return the key in an error response.
    • Use different keys for development and production.
    • Revoke exposed keys immediately.
    • Restrict access to deployment secrets.

    6. Do Not Display Raw AI Output as HTML

    Use htmlspecialchars() for normal text output.

    If a future version intentionally supports Markdown, process it with a well-maintained parser configured to block dangerous HTML and URLs.

    7. Consider Image Privacy

    Users may upload photographs containing faces, addresses, identity cards, vehicle plates, private documents or location information.

    Explain clearly:

    • What is uploaded
    • Which external service processes it
    • Whether the application stores it
    • How long logs are kept
    • Who can access the generated caption

    Avoid keeping uploaded images unless the application actually needs them.

    8. Moderate Public Content Where Necessary

    A public tool may receive disturbing, illegal or abusive content. Establish an acceptable-use policy, reporting process and moderation controls suitable for the audience and jurisdiction.

    9. Avoid Revealing Detailed Errors to Public Users

    During development, the API’s error message helps with debugging. In production, log technical details privately and show the visitor a simpler message.

    For example:

    error_log("Caption API error: " . $apiMessage);
    $error = "We could not generate a caption. Please try again later.";

    Do not log Base64 image data or sensitive image content unnecessarily.


    Common Errors and Solutions

    Error: Call to undefined function curl_init()

    The PHP cURL extension is unavailable or disabled.

    Enable cURL in the active php.ini, then restart PHP or the web server.

    Find the loaded configuration file with:

    php --ini

    Remember that command-line PHP and web-server PHP can load different configuration files.

    Error: The Server Is Missing Its API Configuration

    PHP cannot read OPENAI_API_KEY.

    Confirm that the environment variable exists in the same environment that runs PHP. Setting it in one terminal does not automatically make it available to Apache, PHP-FPM or another terminal.

    Error: HTTP 401

    Common causes include:

    • Missing API key
    • Incorrect API key
    • Revoked key
    • Extra spaces or quote characters
    • The key not reaching the PHP process

    Do not print the full key while debugging. At most, check whether the environment variable exists.

    Error: HTTP 429

    This commonly indicates a rate-limit or quota problem.

    Check the API account’s usage, billing state, project limits and rate limits. Add retry logic with backoff for temporary rate limits, but do not endlessly retry quota failures.

    Error: The Uploaded File Is Rejected

    Check:

    • The actual MIME type
    • The five-megabyte application limit
    • upload_max_filesize
    • post_max_size
    • Reverse-proxy request limits
    • Web-server request limits

    Error: PHP Times Out

    The example gives cURL a 60-second timeout. A slow connection, large image or busy service can exceed a lower server timeout.

    For a production application, consider a background job and status endpoint rather than keeping a web request open for a long time.

    Error: No Caption Was Returned

    Log the response structure in a protected development environment. The result may have been incomplete, refused or returned in an unexpected form.

    Do not assume that every successful HTTP response contains usable caption text.

    Error: Base64 Request Is Too Large

    Base64 representation is larger than the original binary file. Reduce the upload limit, resize the image before sending it or use a file-upload workflow appropriate for the API.


    Optional Improvement: Resize Images Before Sending

    Large camera photographs can consume more bandwidth and memory than a caption requires.

    A production application can resize images using GD or Imagick before encoding them. Preserve the aspect ratio and avoid reducing the image so much that important objects or text disappear.

    For example, a general caption may not require a 6000-pixel-wide photograph. A maximum dimension of 1600 or 2000 pixels may be sufficient for many cases, but the correct value should be tested against your own image set.

    Resizing can:

    • Reduce upload time
    • Reduce PHP memory usage
    • Reduce request size
    • Standardise image dimensions
    • Potentially lower processing cost

    Do not overwrite the user’s original unless that is an explicit product feature.


    Optional Improvement: Return Structured JSON

    A later version may need more than one caption field.

    For example:

    {
        "short_caption": "A child flies a red kite on a beach.",
        "alt_text": "Child flying a red kite beside the sea.",
        "keywords": ["child", "kite", "beach", "sea"],
        "needs_review": false
    }

    Structured output is useful when the result must be saved to a database or sent to another system.

    The application should validate every returned field before using it. JSON produced by a model should not be trusted merely because it looks structured.


    Optional Improvement: Save Caption History

    You can add a database table containing:

    ColumnPurpose
    idUnique record ID
    user_idOwner of the request
    image_pathStored image location, if retained
    caption_styleSelected output type
    captionGenerated text
    modelModel used
    statusPending, completed or failed
    created_atRequest time

    Do not save Base64 strings in a normal database column without a strong reason. Object storage or a protected filesystem is usually more suitable for images, while the database stores a reference.

    Add retention and deletion rules instead of keeping every uploaded image forever.


    Optional Improvement: Use a Queue

    The single-page version waits for the API before returning the webpage.

    That is acceptable for a small tutorial. A busy production application can instead:

    1. Validate the upload.
    2. Save a pending job.
    3. Return a job ID.
    4. Process the image in a worker.
    5. Save the caption.
    6. Let the browser check the job status.

    A queue prevents slow AI requests from occupying all available web workers. It also makes controlled retries easier.

    A queue is not automatically necessary for a private tool or low-traffic site. Add it when request volume, latency and reliability justify the extra complexity.


    Testing Checklist

    Test the application with:

    • A normal JPG photograph
    • A PNG screenshot
    • A WebP image
    • An image just below 5 MB
    • An image above 5 MB
    • A text file renamed to .jpg
    • An empty form submission
    • An invalid API key
    • A missing API key
    • A very dark image
    • A crowded image
    • An image containing small text
    • An image containing a person
    • A portrait-oriented phone image
    • A caption containing quotation marks and symbols

    Also confirm that:

    • The API key does not appear in the HTML source.
    • The API key does not appear in browser developer tools.
    • Returned text is escaped.
    • The submit button disables while waiting.
    • The mobile layout remains usable.
    • Failed requests do not expose stack traces or server paths in production.

    Frequently Asked Questions

    Does this project train an image-captioning model?

    No. It uses an existing multimodal model through an API. Training a vision-and-language model from the beginning requires a large labelled dataset, substantial computing power and considerably more machine-learning knowledge.

    Can PHP understand images by itself?

    PHP can upload, validate, resize and store image files, but the caption is generated by the external AI model. PHP manages the web application and API communication.

    Do I need Composer?

    No. This version uses PHP’s built-in cURL extension, so it does not require a Composer package.

    A larger project may use an HTTP client such as Guzzle or a maintained SDK to organise requests more cleanly.

    Is the OpenAI API included with ChatGPT Plus?

    No. ChatGPT subscriptions and API usage are billed separately. Configure API billing and limits for the API project used by the application.

    Why use the Responses API?

    It accepts multimodal input, including text and images, in a single request and is the current API pattern demonstrated in OpenAI’s image-input documentation.

    Why is the image converted to Base64?

    The image exists temporarily on the PHP server and may not have a public URL. A Base64 data URL allows PHP to send its bytes inside the JSON request.

    Can I send an image URL instead?

    Yes, if the URL is publicly reachable and meets the API’s requirements. Do not let users supply arbitrary URLs without considering server-side request forgery, private-network access and untrusted content.

    Can I use this system to generate alt text?

    It can generate a draft, but good alt text depends on the purpose and context of the image. A human editor should review it. Decorative images may require empty alt text rather than a description.

    Can the AI identify a person in a photograph?

    Do not build the captioning workflow around guessing or confirming identity. The tutorial’s prompts tell the model not to invent names or sensitive personal attributes.

    Will the caption always be correct?

    No. Vision models can miss objects, misunderstand relationships, invent details or misread text. Review important captions before publishing them.

    Can I use the application on shared hosting?

    Possibly. The host must support a suitable PHP version, cURL, Fileinfo, outbound HTTPS requests, environment-based secrets and request sizes large enough for the encoded image.

    Some shared hosts block outbound API requests or provide no safe way to configure secrets. Check with the hosting provider.

    Does the project save uploaded images?

    No. It reads PHP’s temporary upload and sends it to the API. The application does not move it into permanent local storage.

    How much does each caption cost?

    Cost depends on the selected model, image size and detail, prompt size and output length. Model prices can change, so use the official pricing and model pages rather than hard-coding an old estimate into the application.

    Should I use a queue?

    For a simple private tool, probably not. For a public or busy site, a queue can improve reliability and prevent slow API calls from tying up all PHP workers.

    Can I add several languages?

    Yes. Add a language selector and include the chosen language in the prompt.

    For example:

    Write the caption in Bahasa Melayu.

    Validate the selected language against a server-side allow-list instead of inserting unrestricted form text into important instructions.


    Final Result

    We have built a complete AI-powered image-captioning application with PHP.

    The finished system:

    1. Accepts JPG, PNG and WebP uploads.
    2. Previews the selected image in the browser.
    3. Validates file size and MIME type on the server.
    4. Protects the form with a CSRF token.
    5. Keeps the API key out of frontend code.
    6. Converts the image into a Base64 data URL.
    7. Sends text and image input to a vision-capable model.
    8. Supports short, social, alt-text and detailed captions.
    9. Extracts text from the Responses API result.
    10. Escapes the caption before displaying it.
    11. Provides a copy button and clear error messages.

    The single-file version is a strong learning project, but a public system should also add authentication, durable rate limiting, spending controls, private logging, privacy disclosures and monitoring.

    Most importantly, remember that an AI caption is a suggestion rather than guaranteed truth. Review the result before using it for accessibility, publishing or business data.

  • Securing Your Laravel API: Common Vulnerabilities and Solutions

    Laravel provides secure building blocks for authentication, validation, database access and authorization. However, a Laravel API is not automatically secure simply because it uses the framework.

    Most serious API vulnerabilities are caused by application logic: an endpoint checks whether a user is logged in but does not check whether that user owns the requested record, a controller accepts fields it should never accept, or a public endpoint has no request limit.

    This guide explains the most common Laravel API security problems, shows vulnerable code, and replaces it with safer Laravel code.

    The examples use modern Laravel 12 and Laravel 13 conventions. The same security principles also apply to older supported Laravel applications, although some file locations and installation commands may differ.


    Quick Answer

    To secure a Laravel API:

    1. Authenticate protected routes with Laravel Sanctum or Passport.
    2. Authorize every action on every protected resource.
    3. Never trust a record ID supplied by the client.
    4. Validate requests with Form Request classes.
    5. Update models using validated, explicitly allowed fields.
    6. Avoid raw SQL containing request data.
    7. Rate-limit login, password-reset, search, upload and expensive endpoints.
    8. Return only the fields the client needs.
    9. Restrict uploaded file type, size and storage location.
    10. Prevent server-side request forgery when your API fetches URLs.
    11. Keep APP_DEBUG=false and protect the .env file in production.
    12. Log security events without logging passwords, tokens or sensitive personal data.
    13. Use HTTPS and keep Laravel, PHP and Composer packages supported and patched.
    14. Add automated tests that attempt unauthorized access—not only successful requests.

    The most important rule is this:

    Authentication proves who the caller is. Authorization decides what that caller is allowed to do.

    An endpoint needs both when it works with private or restricted data.


    Common Laravel API Vulnerabilities

    VulnerabilityCommon Laravel mistakeSafer solution
    Broken object-level authorizationPost::findOrFail($id) after checking only that the user is logged inPolicies, gates or ownership-scoped queries
    Broken function-level authorizationAny authenticated user can call an admin routeRole or permission checks enforced on the server
    Broken authenticationPermanent tokens, no revocation, no login throttlingSanctum or Passport, short lifetimes where appropriate, revocation and throttling
    Mass assignment$model->update($request->all())Form Requests, $request->validated() and $fillable
    SQL injectionConcatenating request data into raw SQLQuery Builder bindings and allowlists for column names
    Excessive data exposureReturning complete Eloquent modelsAPI Resources and explicit fields
    Unrestricted resource consumptionNo limits on requests, pagination, uploads or exportsRate limits and hard server-side limits
    Unsafe file uploadTrusting extensions or storing files publiclyContent-based validation, generated names and private storage
    SSRFFetching any URL submitted by a userHost allowlists, blocked private IPs, timeouts and redirect controls
    CSRF or CORS mistakesDisabling protections or allowing every origin with credentialsCorrect Sanctum SPA configuration and narrow CORS rules
    Sensitive-data exposureDebug mode, secrets in logs or committed .env filesProduction configuration, secret management and log redaction
    Outdated dependenciesIgnoring security advisoriesSupported versions, composer audit and controlled updates

    These risks overlap with the OWASP API Security Top 10, including broken object-level authorization, broken authentication, unrestricted resource consumption, server-side request forgery and security misconfiguration.


    1. Use Proper API Authentication

    Authentication identifies the user or system making a request.

    For a first-party single-page application, mobile application or simple token-based API, Laravel generally recommends Sanctum. Passport is appropriate when the application genuinely requires OAuth 2 features. See Laravel’s authentication guidance before choosing between them.

    In a modern Laravel application, API support can be installed with:

    php artisan install:api

    Protect private routes with auth:sanctum:

    <?php
    
    use App\Http\Controllers\PostController;
    use Illuminate\Support\Facades\Route;
    
    Route::middleware('auth:sanctum')->group(function () {
        Route::get('/posts', [PostController::class, 'index']);
        Route::post('/posts', [PostController::class, 'store']);
        Route::patch('/posts/{post}', [PostController::class, 'update']);
        Route::delete('/posts/{post}', [PostController::class, 'destroy']);
    });

    A route outside this group is public unless another middleware protects it.

    Do Not Create Your Own Plain-Text Token System

    Avoid adding an api_token column to the users table and comparing raw permanent tokens manually. A home-made implementation may omit hashing, expiry, abilities, rotation, revocation and secure lookup behaviour.

    Sanctum stores a hash of each personal access token in the database. The plain-text token is returned only when it is created, so it must be shown to the user once and then handled like a password.

    $token = $user->createToken(
        'mobile-app',
        ['posts:read', 'posts:write']
    )->plainTextToken;

    Do not write $token to logs or analytics.

    Limit Token Abilities

    Do not give every integration unrestricted access.

    Route::post('/posts', [PostController::class, 'store'])
        ->middleware([
            'auth:sanctum',
            'abilities:posts:write',
        ]);

    Abilities reduce what a token is intended to do. They do not replace model authorization. A token with posts:write must still be prevented from editing another user’s post.

    Revoke Tokens

    Provide a way to revoke the current token:

    public function logout(Request $request): JsonResponse
    {
        $request->user()->currentAccessToken()?->delete();
    
        return response()->json([
            'message' => 'Token revoked.',
        ]);
    }

    You may also revoke all tokens after a password change, account compromise or administrative security action:

    $user->tokens()->delete();

    Decide whether tokens need an expiry time based on the sensitivity of the application. Long-lived integration tokens may be necessary, but they should have narrow abilities, visible last-used information, rotation procedures and immediate revocation support.


    2. Prevent Broken Object-Level Authorization

    Broken object-level authorization, often called BOLA or IDOR, is one of the most common API vulnerabilities.

    Consider this endpoint:

    GET /api/invoices/8421

    If a logged-in user changes 8421 to 8422, can they see another customer’s invoice?

    Vulnerable Example

    public function show(int $id): JsonResponse
    {
        $invoice = Invoice::findOrFail($id);
    
        return response()->json($invoice);
    }

    Placing this controller behind auth:sanctum only proves that the caller is authenticated. It does not prove that the invoice belongs to the caller.

    UUIDs do not solve this problem. A UUID may be harder to guess than an integer, but leaked, logged or shared UUIDs still require authorization checks.

    Solution A: Scope the Query to the Authenticated User

    public function show(Request $request, int $id): InvoiceResource
    {
        $invoice = $request->user()
            ->invoices()
            ->findOrFail($id);
    
        return new InvoiceResource($invoice);
    }

    The query searches only within the authenticated user’s invoices. A record belonging to another user is not returned.

    This approach is particularly useful for strictly owned records.

    Solution B: Use a Laravel Policy

    Generate a policy:

    php artisan make:policy InvoicePolicy --model=Invoice

    Define the authorization rule:

    <?php
    
    namespace App\Policies;
    
    use App\Models\Invoice;
    use App\Models\User;
    
    class InvoicePolicy
    {
        public function view(User $user, Invoice $invoice): bool
        {
            return $user->id === $invoice->user_id;
        }
    
        public function update(User $user, Invoice $invoice): bool
        {
            return $user->id === $invoice->user_id
                && $invoice->status === 'draft';
        }
    }

    Authorize the action in the controller:

    public function show(Invoice $invoice): InvoiceResource
    {
        Gate::authorize('view', $invoice);
    
        return new InvoiceResource($invoice);
    }

    Laravel policies group authorization rules around a model, while gates are useful for actions not tied to one model. Laravel explains both approaches in its authorization documentation.

    Check Every Operation

    Authorization is required for more than show.

    Check all relevant operations:

    • Listing records
    • Viewing one record
    • Creating records under a parent resource
    • Updating records
    • Deleting or restoring records
    • Downloading attachments
    • Exporting data
    • Viewing comments or activity logs
    • Changing status
    • Assigning a record to another user

    A secure show method does not compensate for an insecure update, download or export method.


    3. Prevent Broken Function-Level Authorization

    Object-level authorization asks, “Can this user access this record?”

    Function-level authorization asks, “Can this user perform this type of action at all?”

    Vulnerable Example

    Route::delete('/admin/users/{user}', function (User $user) {
        $user->delete();
    
        return response()->noContent();
    })->middleware('auth:sanctum');

    Every authenticated user can call this route.

    Hiding the delete button in the frontend is not security. Attackers can call the API directly.

    Safer Example

    Use a policy, gate or trusted permission package and enforce the result on the server:

    Gate::define('delete-user', function (User $currentUser, User $targetUser) {
        return $currentUser->is_admin
            && $currentUser->id !== $targetUser->id;
    });

    Then authorize the action:

    public function destroy(User $user): Response
    {
        Gate::authorize('delete-user', $user);
    
        $user->delete();
    
        return response()->noContent();
    }

    For larger applications, use policies or a well-maintained role-and-permission system so the rules remain consistent and testable.


    4. Prevent Mass-Assignment Vulnerabilities

    Mass assignment happens when an array is used to create or update several model attributes at once.

    It is convenient, but dangerous when the array contains fields the user should not control.

    Vulnerable Example

    public function update(Request $request, User $user): JsonResponse
    {
        $user->update($request->all());
    
        return response()->json($user);
    }

    An attacker may submit:

    {
        "name": "Normal Name",
        "is_admin": true,
        "account_balance": 100000
    }

    If the model accepts those attributes, the user may change protected values.

    Use $fillable

    class User extends Authenticatable
    {
        protected $fillable = [
            'name',
            'timezone',
        ];
    }

    Do not use this in a sensitive model without very careful control:

    protected $guarded = [];

    An empty $guarded array makes all attributes mass assignable.

    Use a Form Request and Validated Data

    Create a request class:

    php artisan make:request UpdateProfileRequest
    <?php
    
    namespace App\Http\Requests;
    
    use Illuminate\Foundation\Http\FormRequest;
    
    class UpdateProfileRequest extends FormRequest
    {
        public function authorize(): bool
        {
            return true;
        }
    
        public function rules(): array
        {
            return [
                'name' => ['required', 'string', 'max:100'],
                'timezone' => ['required', 'timezone'],
            ];
        }
    }

    Then update only validated fields:

    public function update(UpdateProfileRequest $request): UserResource
    {
        $user = $request->user();
        $user->update($request->validated());
    
        return new UserResource($user->refresh());
    }

    For additional clarity, select fields explicitly:

    $user->update(
        $request->safe()->only([
            'name',
            'timezone',
        ])
    );

    Treat $fillable, validation and authorization as separate layers:

    • $fillable controls which model attributes may be mass assigned.
    • Validation controls acceptable request structure and values.
    • Authorization controls whether the current user may perform the action.

    One layer does not replace the others.


    5. Validate Every External Input

    Do not validate only forms. Validate all external data, including:

    • JSON request bodies
    • Query-string filters
    • Route parameters
    • Uploaded files
    • Webhook payloads
    • Third-party API responses before important use
    • Import files
    • Sorting and pagination parameters

    Example Form Request

    <?php
    
    namespace App\Http\Requests;
    
    use Illuminate\Foundation\Http\FormRequest;
    use Illuminate\Validation\Rule;
    
    class StoreOrderRequest extends FormRequest
    {
        public function authorize(): bool
        {
            return $this->user() !== null;
        }
    
        public function rules(): array
        {
            return [
                'product_id' => [
                    'required',
                    'integer',
                    Rule::exists('products', 'id')
                        ->where('is_active', true),
                ],
                'quantity' => ['required', 'integer', 'min:1', 'max:20'],
                'delivery_note' => ['nullable', 'string', 'max:500'],
            ];
        }
    }

    Validation prevents malformed values, but business rules must still be checked. For example, a valid product_id does not prove that the customer may order a restricted product.

    Set Maximum Limits

    Never accept an unlimited per_page value:

    $validated = $request->validate([
        'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
    ]);
    
    $perPage = $validated['per_page'] ?? 20;
    
    $orders = Order::query()->paginate($perPage);

    The same principle applies to date ranges, batch sizes, search lengths, export rows, nested JSON arrays and file sizes.


    6. Prevent SQL Injection

    Laravel’s Query Builder and Eloquent use parameter binding for normal values. Problems appear when developers concatenate request data into raw SQL or let users choose raw column names and expressions.

    Vulnerable Raw Query

    $email = $request->input('email');
    
    $users = DB::select(
        "SELECT * FROM users WHERE email = '$email'"
    );

    Use Bindings

    $users = DB::select(
        'SELECT * FROM users WHERE email = ?',
        [$request->string('email')->toString()]
    );

    Better still, use Eloquent or Query Builder when possible:

    $user = User::query()
        ->where('email', $request->string('email'))
        ->first();

    Laravel warns that raw expressions are inserted into queries as strings and may introduce SQL injection. When a raw expression is necessary, use bindings rather than concatenation. See the official Query Builder documentation.

    Allowlist Sort Columns

    Database bindings protect values, not arbitrary identifiers such as column names.

    Do not do this:

    $users = User::orderBy(
        $request->input('sort'),
        $request->input('direction')
    )->get();

    Use explicit allowlists:

    $validated = $request->validate([
        'sort' => ['sometimes', 'in:name,created_at'],
        'direction' => ['sometimes', 'in:asc,desc'],
    ]);
    
    $sort = $validated['sort'] ?? 'created_at';
    $direction = $validated['direction'] ?? 'desc';
    
    $users = User::query()
        ->orderBy($sort, $direction)
        ->paginate(20);

    Use the same technique for selectable report fields, aggregate functions, table names and search operators.


    7. Add Rate Limits and Resource Limits

    Rate limiting reduces brute-force attacks, scraping, accidental loops and denial-of-service pressure.

    Laravel includes a rate-limiting abstraction backed by the application’s cache. For distributed production servers, a shared store such as Redis prevents each application server from maintaining an independent counter.

    Define a Named API Limiter

    <?php
    
    namespace App\Providers;
    
    use Illuminate\Cache\RateLimiting\Limit;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\RateLimiter;
    use Illuminate\Support\ServiceProvider;
    
    class AppServiceProvider extends ServiceProvider
    {
        public function boot(): void
        {
            RateLimiter::for('api-standard', function (Request $request) {
                $key = $request->user()?->id ?: $request->ip();
    
                return Limit::perMinute(60)->by($key);
            });
        }
    }

    Apply it to routes:

    Route::middleware([
        'auth:sanctum',
        'throttle:api-standard',
    ])->group(function () {
        Route::apiResource('orders', OrderController::class);
    });

    Laravel documents named limiters and route middleware in its routing documentation.

    Sensitive Endpoints Need Separate Limits

    Do not use one generous limit for everything. Apply stricter limits to:

    • Login attempts
    • Registration
    • Password-reset requests
    • One-time-password verification
    • Email or SMS sending
    • Search endpoints
    • File uploads
    • PDF or report generation
    • AI or third-party paid API calls
    • Data exports
    • Webhook retries

    Rate limiting is only one control. Also set:

    • Maximum request-body size at the web server
    • Maximum upload size
    • Maximum pagination size
    • Database query timeouts where appropriate
    • HTTP client connection and response timeouts
    • Queue job timeouts and retry limits
    • Maximum batch-operation size
    • Maximum export date range

    Without hard limits, one accepted request may still consume excessive memory, CPU, database time or third-party credits.


    8. Return Only the Data the Client Needs

    An API can be properly authenticated and still leak data by returning complete models.

    Risky Example

    return response()->json(User::findOrFail($id));

    The response may include internal fields that the current client does not need, such as:

    • Internal status flags
    • Administrative notes
    • Provider identifiers
    • Verification timestamps
    • Billing references
    • Soft-delete timestamps
    • Security-related metadata

    Laravel normally hides the password and remember token on the default User model, but custom models and future columns may not be protected automatically.

    Use an API Resource

    Create a resource:

    php artisan make:resource UserResource
    <?php
    
    namespace App\Http\Resources;
    
    use Illuminate\Http\Request;
    use Illuminate\Http\Resources\Json\JsonResource;
    
    class UserResource extends JsonResource
    {
        public function toArray(Request $request): array
        {
            return [
                'id' => $this->id,
                'name' => $this->name,
                'avatar_url' => $this->avatar_url,
                'created_at' => $this->created_at?->toIso8601String(),
            ];
        }
    }

    Return the resource:

    return new UserResource($user);

    API Resources create an explicit response contract. They are clearer and safer than exposing every current and future database column.

    Also review nested relationships. Returning a safe user object with an unrestricted orders, payments or notes relationship may still leak private data.


    9. Secure File Uploads

    File upload endpoints require more than checking the filename extension.

    Laravel can validate a file’s content-derived MIME type using the fluent File rule.

    use Illuminate\Validation\Rules\File;
    
    $validated = $request->validate([
        'document' => [
            'required',
            File::types(['pdf', 'jpg', 'jpeg', 'png'])
                ->max('5mb'),
        ],
    ]);

    Laravel’s validation documentation notes that SVG files are not allowed by the normal image rule by default because SVG can introduce cross-site scripting risks. Do not enable SVG uploads unless the application has a safe sanitisation and serving strategy. See Laravel file validation.

    Store with a Generated Name

    $path = $validated['document']->store(
        'private/documents',
        'local'
    );

    Laravel generates the stored filename. Do not use an untrusted original filename as the filesystem path.

    Keep Private Files Private

    Do not store identity documents, invoices, medical files or private attachments on a directly public disk.

    Serve them through an authorized controller or use short-lived signed storage URLs after authorization.

    public function download(Document $document): BinaryFileResponse
    {
        Gate::authorize('view', $document);
    
        return response()->download(
            Storage::disk('local')->path($document->path),
            $document->original_name
        );
    }

    Additional controls may include malware scanning, image re-encoding, archive rejection, decompression limits and separate storage domains. Never execute uploaded files.


    10. Prevent Server-Side Request Forgery

    Server-side request forgery, or SSRF, occurs when an attacker causes your server to request an unintended address.

    A vulnerable endpoint may accept a URL for an image importer, webhook tester or page preview:

    $response = Http::get($request->input('url'));

    An attacker may try to reach:

    • localhost
    • Internal admin panels
    • Private network services
    • Cloud metadata endpoints
    • Services protected from the public internet

    The safest approach is to avoid arbitrary destinations and allow only known hosts.

    $validated = $request->validate([
        'url' => ['required', 'url:https'],
    ]);
    
    $url = $validated['url'];
    $host = strtolower((string) parse_url($url, PHP_URL_HOST));
    
    $allowedHosts = [
        'images.example.com',
        'cdn.example.com',
    ];
    
    abort_unless(
        in_array($host, $allowedHosts, true),
        422,
        'The URL host is not allowed.'
    );
    
    $response = Http::connectTimeout(3)
        ->timeout(8)
        ->withoutRedirecting()
        ->get($url);

    If arbitrary public URLs are a genuine requirement, the protection must be stronger:

    • Permit only https where possible.
    • Resolve the hostname and reject loopback, private, link-local, multicast and reserved IP ranges.
    • Re-check every redirect destination.
    • Protect against DNS rebinding.
    • Restrict outbound traffic at the network layer.
    • Set connection, response and size limits.
    • Do not forward the user’s authorization headers or cookies.

    URL validation alone does not prove that a destination is safe.


    11. Configure CSRF and CORS Correctly

    CSRF and CORS solve different problems.

    • CSRF protection prevents another website from causing a browser to perform an unwanted authenticated action.
    • CORS controls which browser origins may read or send permitted cross-origin requests.

    CORS is not authentication. Command-line tools, mobile apps and malicious servers are not stopped by a browser’s CORS policy.

    Bearer-Token APIs

    A stateless API using a token in the Authorization header is normally not authenticated automatically by browser cookies. Traditional CSRF risk is therefore different from cookie-authenticated routes.

    The token must still be protected against theft, logging and insecure client storage.

    Sanctum SPA Authentication

    For a first-party SPA using Sanctum’s cookie-based authentication, follow Laravel’s stateful SPA configuration and CSRF-cookie flow. Do not disable CSRF verification simply to make requests work.

    The frontend typically first requests:

    /sanctum/csrf-cookie

    It then sends the login or authenticated request with credentials configured correctly. Refer to the current Laravel Sanctum documentation because the required middleware and client configuration depend on the Laravel version and frontend setup.

    Keep CORS Narrow

    If credentials are permitted, list trusted origins explicitly. Do not combine credentialed requests with a broad or reflected origin policy.

    Review:

    • Allowed origins
    • Allowed methods
    • Allowed headers
    • Credential support
    • Preflight caching
    • Development origins accidentally left in production

    Only publish the CORS configuration file if the defaults need to be changed:

    php artisan config:publish cors

    12. Protect Secrets and Production Configuration

    Disable Debug Mode in Production

    Production should use:

    APP_ENV=production
    APP_DEBUG=false

    Laravel’s configuration documentation warns that production debug mode can expose sensitive configuration values.

    Do not return stack traces, SQL queries, internal file paths or exception details to API clients. Send a stable error structure and keep full diagnostic details in protected server logs.

    Protect the .env File

    Never commit .env to Git. Ensure the web server’s document root points to Laravel’s public directory—not the project root.

    Secrets include:

    • Database passwords
    • APP_KEY
    • API tokens
    • OAuth client secrets
    • Payment-provider keys
    • Mail credentials
    • Cloud storage credentials
    • Webhook signing secrets

    Use environment variables or a secret-management service. Give each environment separate credentials and the minimum required privileges.

    Do not casually rotate APP_KEY on an existing application. Data encrypted with the old key may become unreadable unless a planned key-rotation procedure and previous-key support are in place.

    Cache Production Configuration

    During deployment, run:

    php artisan config:cache
    php artisan route:cache
    php artisan view:cache

    Access environment values through configuration files and config() rather than calling env() throughout application code.


    13. Do Not Leak Secrets Through Logs

    Logs are essential for investigating attacks, but they can become another sensitive database.

    Do not log:

    • Passwords
    • Full bearer tokens
    • Session cookies
    • CSRF tokens
    • Credit-card data
    • Complete identity documents
    • Password-reset links
    • Private webhook signatures
    • Full request bodies containing personal data

    Risky code:

    Log::info('Login request', $request->all());

    Safer code:

    Log::warning('Failed login attempt', [
        'email_hash' => hash('sha256', strtolower($request->input('email'))),
        'ip' => $request->ip(),
        'user_agent' => $request->userAgent(),
    ]);

    Even hashed identifiers may be personal data depending on context and applicable law. Collect only what the security and support teams genuinely need, restrict log access and configure retention.

    Useful security events include:

    • Repeated failed authentication
    • Token creation and revocation
    • Password and email changes
    • Two-factor authentication changes
    • Administrative actions
    • Permission changes
    • Large exports
    • Rejected webhook signatures
    • Unusual rate-limit activity

    Do not show internal security reasons to an attacker. For example, a login response can remain generic while the server records whether the account was missing, disabled or supplied an incorrect password.


    14. Verify Webhook Signatures

    A webhook endpoint is public because an external provider must reach it. Public does not mean unverified.

    Do not trust a webhook merely because it contains a plausible order ID or payment status.

    Verify the signature using the provider’s official SDK or documented algorithm, using the raw request body when required.

    General structure:

    public function handle(Request $request): Response
    {
        $payload = $request->getContent();
        $signature = $request->header('X-Provider-Signature');
    
        abort_unless(
            $this->signatureIsValid($payload, $signature),
            401
        );
    
        // Process the verified event idempotently.
    
        return response()->noContent();
    }

    Also:

    • Reject missing or invalid signatures.
    • Enforce timestamp tolerance when supported.
    • Store provider event IDs to prevent duplicate processing.
    • Make processing idempotent.
    • Queue slow work after verification.
    • Rate-limit or network-restrict endpoints when compatible with the provider.
    • Keep signing secrets separate for development and production.

    Never invent a generic signature procedure when a provider supplies an official one.


    15. Use HTTPS and Secure Infrastructure

    API credentials and private data must be encrypted in transit with HTTPS.

    Redirect HTTP to HTTPS at the load balancer or web server and renew certificates automatically. Configure Laravel’s trusted proxy settings correctly so URL generation and secure-cookie behaviour recognize the original HTTPS connection.

    Infrastructure controls should include:

    • A web root pointing only to public
    • No directory listing
    • Restricted file permissions
    • A database not exposed publicly
    • Separate database users with limited privileges
    • Firewall rules permitting only required ports
    • Protected Redis, queue and monitoring services
    • Encrypted backups with tested restoration
    • Outbound network restrictions for sensitive environments
    • Security headers appropriate to the API and any browser frontend

    Laravel security cannot compensate for a publicly exposed database, Redis server or administration panel.


    16. Keep Dependencies Supported and Patched

    Check the application for known Composer advisories:

    composer audit

    If the project includes frontend dependencies, also review:

    npm audit

    An audit warning does not mean that every suggested automatic update is safe. Review:

    1. Which direct or transitive package is affected.
    2. Whether the vulnerable code path is used.
    3. Which fixed version is available.
    4. Whether the update contains breaking changes.
    5. Whether tests and a staging deployment pass.

    Keep these components on supported versions:

    • PHP
    • Laravel framework
    • Composer
    • Authentication packages
    • Queue and cache clients
    • Web server and operating system
    • JavaScript dependencies used by the frontend

    Remove packages the application no longer uses. Every unnecessary dependency increases maintenance and attack surface.


    17. Avoid User Enumeration

    Authentication and account-recovery endpoints can reveal whether an email address exists.

    Risky responses:

    { "message": "No account uses this email." }

    and:

    { "message": "The password is incorrect." }

    These allow attackers to build a list of registered users.

    For login, return a generic response:

    { "message": "The provided credentials are invalid." }

    For password reset, return the same public response whether or not the account exists:

    {
        "message": "If an account matches that email, reset instructions will be sent."
    }

    Use rate limiting as well. Avoid creating obvious response-time differences between existing and nonexistent accounts.


    18. Test Security Failures

    Many test suites verify only that authorized users receive 200 OK. Security tests must also prove that unauthorized requests fail.

    Test Cross-User Record Access

    use App\Models\Invoice;
    use App\Models\User;
    use Laravel\Sanctum\Sanctum;
    
    it('prevents a user from viewing another users invoice', function () {
        $owner = User::factory()->create();
        $attacker = User::factory()->create();
    
        $invoice = Invoice::factory()
            ->for($owner)
            ->create();
    
        Sanctum::actingAs($attacker);
    
        $this->getJson("/api/invoices/{$invoice->id}")
            ->assertForbidden();
    });

    If the controller intentionally scopes the query to the user’s relationship, expect 404 Not Found instead:

    ->assertNotFound();

    Both approaches can be valid. Apply one behaviour consistently and do not leak extra record information unintentionally.

    Test Mass Assignment

    it('does not allow a profile update to grant admin access', function () {
        $user = User::factory()->create([
            'is_admin' => false,
        ]);
    
        Sanctum::actingAs($user);
    
        $this->patchJson('/api/profile', [
            'name' => 'Updated Name',
            'timezone' => 'Asia/Kuala_Lumpur',
            'is_admin' => true,
        ])->assertSuccessful();
    
        expect($user->refresh()->is_admin)->toBeFalse();
    });

    Depending on the validation contract, it may be better to reject unknown fields with 422 Unprocessable Content rather than ignore them. Whichever behaviour you choose, test it.

    Additional Security Tests

    Add tests for:

    • Requests without authentication
    • Expired or revoked tokens
    • Tokens missing required abilities
    • Normal users calling admin endpoints
    • Cross-tenant access
    • Invalid webhook signatures
    • Replay of the same webhook event
    • Excessive pagination values
    • Disallowed sort columns
    • Oversized and disallowed file uploads
    • Rate-limit responses
    • Sensitive fields missing from API resources
    • Error responses not containing stack traces

    A test for every discovered authorization bug prevents the same vulnerability from returning later.


    A Secure Laravel API Controller Example

    The following controller combines authentication through route middleware, authorization, validation, safe assignment and an API Resource.

    <?php
    
    namespace App\Http\Controllers;
    
    use App\Http\Requests\UpdateInvoiceRequest;
    use App\Http\Resources\InvoiceResource;
    use App\Models\Invoice;
    use Illuminate\Support\Facades\Gate;
    
    class InvoiceController extends Controller
    {
        public function show(Invoice $invoice): InvoiceResource
        {
            Gate::authorize('view', $invoice);
    
            return new InvoiceResource($invoice);
        }
    
        public function update(
            UpdateInvoiceRequest $request,
            Invoice $invoice
        ): InvoiceResource {
            Gate::authorize('update', $invoice);
    
            $invoice->update(
                $request->safe()->only([
                    'billing_name',
                    'billing_address',
                    'notes',
                ])
            );
    
            return new InvoiceResource($invoice->refresh());
        }
    }

    Routes:

    Route::middleware([
        'auth:sanctum',
        'throttle:api-standard',
    ])->group(function () {
        Route::get('/invoices/{invoice}', [
            InvoiceController::class,
            'show',
        ]);
    
        Route::patch('/invoices/{invoice}', [
            InvoiceController::class,
            'update',
        ]);
    });

    This structure does not make the entire application automatically secure, but it keeps important controls visible and testable.


    Laravel API Security Checklist

    Use this checklist before releasing an API.

    Authentication

    • Private routes require the correct authentication middleware.
    • Sanctum or Passport is used instead of a home-made token system.
    • Tokens can be revoked.
    • Token abilities are limited where useful.
    • Login and password-reset endpoints are rate-limited.
    • Sensitive accounts support multi-factor authentication where appropriate.

    Authorization

    • Every endpoint that accepts a record ID checks access to that record.
    • Index endpoints return only permitted records.
    • Download and export endpoints are authorized.
    • Admin functions check roles or permissions on the server.
    • Cross-tenant access has dedicated automated tests.

    Input and Database

    • Form Requests validate bodies, query strings and files.
    • Pagination, array and batch sizes have maximum values.
    • Models use carefully reviewed $fillable attributes.
    • Controllers update from validated allowlisted fields.
    • Request data is never concatenated into raw SQL.
    • Sort columns and query operators use allowlists.

    Responses and Files

    • API Resources expose only required fields.
    • Sensitive relationships are not returned accidentally.
    • Private uploads are not directly public.
    • Uploaded type and size are validated.
    • Stored filenames are generated by the application.
    • Downloads perform authorization before returning a file.

    Configuration and Operations

    • APP_ENV=production is set in production.
    • APP_DEBUG=false is set in production.
    • The web root points to Laravel’s public directory.
    • The .env file is not committed or publicly readable.
    • HTTPS is enforced.
    • Logs exclude passwords, tokens and unnecessary personal data.
    • Backups are encrypted and restoration is tested.
    • Laravel, PHP and dependencies are supported and patched.
    • composer audit is reviewed during the release process.
    • Monitoring alerts on repeated authentication failures and unusual errors.

    Common Laravel API Security Mistakes

    “The Route Uses auth:sanctum, So It Is Secure”

    auth:sanctum verifies identity. It does not automatically decide which invoices, projects, files or users that identity may access.

    “We Use UUIDs, So IDs Cannot Be Guessed”

    UUIDs reduce simple enumeration but do not replace authorization. IDs leak through browser history, logs, screenshots, emails, analytics and related API responses.

    “CORS Blocks Attackers”

    CORS is enforced by browsers. It does not prevent direct requests from scripts, servers or mobile tools.

    “Validation Prevents Mass Assignment”

    Only if the application updates from the validated data. Calling $request->all() after validation can reintroduce unwanted fields.

    “Eloquent Prevents Every SQL Injection”

    Normal value binding is safe, but unsafe raw expressions and user-controlled column names can still create injection risks.

    “The Frontend Hides the Admin Button”

    Frontend visibility is not authorization. The backend must reject unauthorized calls.

    “A Successful Request Test Is Enough”

    Security depends on rejected requests. Test anonymous users, other users, lower roles, revoked tokens and malformed data.


    Frequently Asked Questions

    Is Laravel secure by default?

    Laravel provides secure tools and sensible defaults, including password hashing, query parameter binding, validation, CSRF protection and authentication middleware. Application-specific authorization, data exposure, rate limits, token policy and server configuration remain the developer’s responsibility.

    Should I use Laravel Sanctum or Passport?

    Use Sanctum for most first-party SPAs, mobile applications and simple token APIs. Use Passport when the application specifically requires OAuth 2 flows and capabilities. Do not choose Passport merely because it sounds more secure; unnecessary complexity can create configuration mistakes.

    Is auth:sanctum enough to protect an API route?

    No. It authenticates the caller. You must still authorize access to records and actions using scoped queries, gates, policies, roles or permissions.

    Does Laravel prevent SQL injection automatically?

    Eloquent and Query Builder bind normal values, which protects common queries. Raw expressions, string concatenation and user-controlled identifiers can still be dangerous. Use bindings and allowlists.

    Should an API return 403 or 404 for another user’s record?

    Both approaches are used. A policy commonly returns 403 Forbidden; an ownership-scoped query commonly returns 404 Not Found. Returning 404 may reveal less about whether the record exists. Choose intentionally, keep behaviour consistent and test it.

    Are UUIDs safer than numeric IDs?

    They are harder to enumerate, but they are not an authorization control. Every object still requires an access check.

    Should I disable CSRF protection for my API?

    Do not disable it blindly. Pure bearer-token endpoints and cookie-authenticated SPA endpoints have different requirements. Sanctum’s stateful SPA authentication relies on correct cookies, domains, credentials and CSRF handling.

    How should API tokens be stored in a frontend?

    For first-party browser SPAs, Laravel Sanctum’s secure, HTTP-only cookie approach is normally preferable to placing long-lived tokens in browser storage. Native mobile applications should use the platform’s secure credential storage. Never expose tokens in URLs, logs or analytics.

    How often should dependencies be updated?

    Review security advisories continuously or during every release pipeline, apply critical fixes promptly and schedule regular supported-version updates. Test changes in staging rather than ignoring updates until the framework is no longer supported.

    What is the most important Laravel API security test?

    For applications containing private records, create two users and prove that one user cannot view, update, delete, download or export the other user’s data. Repeat the test across every tenant and role boundary.


    Final Summary

    Laravel offers strong security features, but developers must apply them consistently.

    The most important improvements are:

    • Authenticate protected API routes.
    • Authorize every protected record and action.
    • Scope queries to the current user or tenant.
    • Validate all external input.
    • Update models only with validated, allowlisted fields.
    • Avoid unsafe raw SQL and user-controlled identifiers.
    • Apply request and resource limits.
    • Return explicit API Resources instead of full models.
    • Secure uploads and outbound URL requests.
    • Keep production debug mode disabled.
    • Protect secrets and remove them from logs.
    • Verify webhook signatures.
    • Use HTTPS and supported dependencies.
    • Test that forbidden requests fail.

    Do not begin by adding complicated security packages. First make the application’s trust boundaries clear: who is calling, which record they are requesting, which action they want to perform, which fields they may change, and how much work one request is allowed to trigger.

    When those checks are explicit, centralized and covered by tests, a Laravel API becomes much harder to misuse.

  • PHP Loops for Kids and Beginners: for, while and foreach — Part 7

    Loops are used when we want PHP to repeat an instruction.

    Instead of writing the same code five, ten or even one hundred times, we can write it once and tell PHP how many times to repeat it.

    In Part 7 of this PHP tutorial for kids and beginners, you will learn:

    • What a loop is
    • Why programmers use loops
    • How to create a for loop
    • How to use a while loop
    • How a do...while loop works
    • How to use foreach with arrays
    • How to stop or skip part of a loop
    • How to avoid an infinite loop
    • How to build a multiplication-table project

    All the examples use beginner-friendly PHP 8 syntax.


    Quick Answer

    A PHP loop repeats a block of code.

    For example:

    <?php
    
    for ($number = 1; $number <= 5; $number++) {
        echo $number . "<br>";
    }
    

    The output is:

    1
    2
    3
    4
    5
    

    PHP repeats the echo instruction until $number becomes greater than 5.


    What Is a Loop?

    Imagine that a teacher asks you to write:

    I will practise PHP.
    

    five times.

    Without a loop, you might write:

    <?php
    
    echo "I will practise PHP.<br>";
    echo "I will practise PHP.<br>";
    echo "I will practise PHP.<br>";
    echo "I will practise PHP.<br>";
    echo "I will practise PHP.<br>";
    

    This works, but it repeats the same instruction many times.

    With a loop, we can write:

    <?php
    
    for ($count = 1; $count <= 5; $count++) {
        echo "I will practise PHP.<br>";
    }
    

    Both examples produce the same result.

    The loop is shorter and easier to change. If we want the message to appear 100 times, we only need to change 5 to 100.


    The Main PHP Loops

    PHP provides several types of loops.

    LoopBest used when
    forYou know how many times to repeat something
    whileRepeat while a condition remains true
    do...whileRun the code once before checking the condition
    foreachGo through the items in an array

    You do not need to memorise everything immediately. Try each example and change the values to see what happens.


    1. The PHP for Loop

    A for loop is useful when we know how many times something should repeat.

    Here is a basic example:

    <?php
    
    for ($number = 1; $number <= 5; $number++) {
        echo "Number: " . $number . "<br>";
    }
    

    The output is:

    Number: 1
    Number: 2
    Number: 3
    Number: 4
    Number: 5
    

    A for loop has three important parts:

    for (starting_value; condition; change) {
        // Code to repeat
    }
    

    In our example:

    for ($number = 1; $number <= 5; $number++)
    

    This means:

    PartCodeMeaning
    Starting value$number = 1Begin counting at 1
    Condition$number <= 5Continue while the number is 5 or less
    Change$number++Add 1 after every round

    The characters ++ mean “increase by one”.

    This:

    $number++;
    

    is similar to:

    $number = $number + 1;
    

    Counting Backwards

    A loop can also count backwards.

    <?php
    
    for ($number = 5; $number >= 1; $number--) {
        echo $number . "<br>";
    }
    
    echo "Blast off!";
    

    The output is:

    5
    4
    3
    2
    1
    Blast off!
    

    The characters -- reduce the value by one after each round.


    Counting in Twos

    We do not always need to add one.

    <?php
    
    for ($number = 2; $number <= 10; $number += 2) {
        echo $number . "<br>";
    }
    

    The output is:

    2
    4
    6
    8
    10
    

    The code:

    $number += 2;
    

    means:

    $number = $number + 2;
    

    We can use this to display even numbers.


    Displaying Odd Numbers

    Start at 1 and increase the number by 2:

    <?php
    
    for ($number = 1; $number <= 10; $number += 2) {
        echo $number . "<br>";
    }
    

    The output is:

    1
    3
    5
    7
    9
    

    Using a for Loop with HTML

    PHP can use a loop to create HTML elements.

    <!DOCTYPE html>
    <html>
    <head>
        <title>My PHP List</title>
    </head>
    <body>
    
    <h1>Five Stars</h1>
    
    <?php
    
    for ($star = 1; $star <= 5; $star++) {
        echo "<p>⭐ Star " . $star . "</p>";
    }
    
    ?>
    
    </body>
    </html>
    

    PHP produces five HTML paragraphs.

    This is one reason loops are useful in website development. They can generate lists, table rows, cards, menus and other repeated elements.


    2. The PHP while Loop

    A while loop continues running while its condition is true.

    <?php
    
    $score = 1;
    
    while ($score <= 5) {
        echo "Score: " . $score . "<br>";
        $score++;
    }
    

    The output is:

    Score: 1
    Score: 2
    Score: 3
    Score: 4
    Score: 5
    

    The loop works like this:

    1. $score starts at 1.
    2. PHP checks whether $score <= 5.
    3. If the condition is true, PHP runs the code inside the loop.
    4. $score++ increases the score by one.
    5. PHP checks the condition again.
    6. The loop stops when the score becomes 6.

    When Should We Use while?

    Use a while loop when the number of repetitions depends on a condition.

    For example, imagine that a game character starts with 5 energy points:

    <?php
    
    $energy = 5;
    
    while ($energy > 0) {
        echo "The player has " . $energy . " energy left.<br>";
        $energy--;
    }
    
    echo "The player needs to rest.";
    

    The loop continues while the player has energy.

    The output is:

    The player has 5 energy left.
    The player has 4 energy left.
    The player has 3 energy left.
    The player has 2 energy left.
    The player has 1 energy left.
    The player needs to rest.
    

    Be Careful with Infinite Loops

    An infinite loop never stops.

    This code is incorrect:

    <?php
    
    $number = 1;
    
    while ($number <= 5) {
        echo $number;
    }
    

    The value of $number never changes. It remains 1, so the condition is always true.

    The corrected version is:

    <?php
    
    $number = 1;
    
    while ($number <= 5) {
        echo $number . "<br>";
        $number++;
    }
    

    Always check that something inside a while loop will eventually make its condition false.

    If you accidentally run an infinite loop using PHP’s development server, stop the server with Ctrl + C.


    3. The PHP do…while Loop

    A do...while loop is similar to a while loop.

    The important difference is that a do...while loop runs its code before checking the condition.

    <?php
    
    $number = 1;
    
    do {
        echo "Number: " . $number . "<br>";
        $number++;
    } while ($number <= 5);
    

    The output is:

    Number: 1
    Number: 2
    Number: 3
    Number: 4
    Number: 5
    

    Notice the semicolon here:

    } while ($number <= 5);
    

    It is required at the end of a do...while loop.


    while Compared with do…while

    Consider this while loop:

    <?php
    
    $number = 10;
    
    while ($number <= 5) {
        echo $number;
    }
    

    Nothing is displayed because the condition is false before the loop begins.

    Now try a do...while loop:

    <?php
    
    $number = 10;
    
    do {
        echo $number;
    } while ($number <= 5);
    

    The output is:

    10
    

    The code runs once before PHP checks the condition.

    Use do...while when an instruction must happen at least once.


    4. The PHP foreach Loop

    The foreach loop is designed for arrays.

    Suppose we have an array containing several fruits:

    <?php
    
    $fruits = [
        "Apple",
        "Banana",
        "Orange",
        "Mango"
    ];
    

    We can display every fruit with:

    <?php
    
    $fruits = [
        "Apple",
        "Banana",
        "Orange",
        "Mango"
    ];
    
    foreach ($fruits as $fruit) {
        echo $fruit . "<br>";
    }
    

    The output is:

    Apple
    Banana
    Orange
    Mango
    

    During each round, PHP places the current array item inside $fruit.

    The loop works like this:

    RoundValue of $fruit
    1Apple
    2Banana
    3Orange
    4Mango

    PHP stops automatically after it reaches the final item.


    Creating an HTML List with foreach

    We can use foreach to build a proper HTML list.

    <?php
    
    $subjects = [
        "Mathematics",
        "Science",
        "English",
        "Computer Studies"
    ];
    
    echo "<ul>";
    
    foreach ($subjects as $subject) {
        echo "<li>" . $subject . "</li>";
    }
    
    echo "</ul>";
    

    The browser displays:

    • Mathematics
    • Science
    • English
    • Computer Studies

    A cleaner way is to combine PHP with HTML:

    <?php
    
    $subjects = [
        "Mathematics",
        "Science",
        "English",
        "Computer Studies"
    ];
    
    ?>
    
    <h2>My Subjects</h2>
    
    <ul>
        <?php foreach ($subjects as $subject): ?>
            <li><?php echo $subject; ?></li>
        <?php endforeach; ?>
    </ul>
    

    The alternative syntax:

    foreach (...):
    

    ends with:

    endforeach;
    

    This style can be easier to read when PHP is mixed with HTML.


    foreach with Keys and Values

    An associative array stores information using named keys.

    <?php
    
    $student = [
        "name" => "Aina",
        "age" => 13,
        "favourite_subject" => "Science"
    ];
    

    We can access both the key and value:

    <?php
    
    $student = [
        "name" => "Aina",
        "age" => 13,
        "favourite_subject" => "Science"
    ];
    
    foreach ($student as $key => $value) {
        echo $key . ": " . $value . "<br>";
    }
    

    The output is:

    name: Aina
    age: 13
    favourite_subject: Science
    

    In this loop:

    foreach ($student as $key => $value)
    
    • $key contains the name of the array item.
    • $value contains its value.

    A Better Student Profile

    We can create friendly labels instead of displaying the original keys:

    <?php
    
    $student = [
        "Name" => "Aina",
        "Age" => 13,
        "Favourite Subject" => "Science"
    ];
    
    foreach ($student as $label => $value) {
        echo "<strong>" . $label . ":</strong> ";
        echo $value . "<br>";
    }
    

    The result is:

    Name: Aina
    Age: 13
    Favourite Subject: Science
    

    5. Using break to Stop a Loop

    The break command stops a loop immediately.

    <?php
    
    for ($number = 1; $number <= 10; $number++) {
        if ($number === 6) {
            break;
        }
    
        echo $number . "<br>";
    }
    

    The output is:

    1
    2
    3
    4
    5
    

    When $number becomes 6, PHP runs break and exits the loop.

    This can be useful when PHP has already found the item it was searching for.


    Searching an Array with break

    <?php
    
    $animals = [
        "Cat",
        "Rabbit",
        "Tiger",
        "Elephant"
    ];
    
    foreach ($animals as $animal) {
        if ($animal === "Tiger") {
            echo "Tiger found!";
            break;
        }
    
        echo "Checking " . $animal . "...<br>";
    }
    

    The output is:

    Checking Cat...
    Checking Rabbit...
    Tiger found!
    

    PHP does not continue to the elephant because the loop has already stopped.


    6. Using continue to Skip One Round

    The continue command skips the remaining code in the current round and moves to the next one.

    <?php
    
    for ($number = 1; $number <= 5; $number++) {
        if ($number === 3) {
            continue;
        }
    
        echo $number . "<br>";
    }
    

    The output is:

    1
    2
    4
    5
    

    PHP skips the echo instruction when the number is 3.

    Unlike break, continue does not stop the complete loop.

    CommandWhat it does
    breakStops the entire loop
    continueSkips the current round

    7. Loops Inside Other Loops

    A loop can be placed inside another loop. This is called a nested loop.

    <?php
    
    for ($row = 1; $row <= 3; $row++) {
        for ($column = 1; $column <= 3; $column++) {
            echo "Row " . $row;
            echo ", Column " . $column;
            echo "<br>";
        }
    }
    

    The output is:

    Row 1, Column 1
    Row 1, Column 2
    Row 1, Column 3
    Row 2, Column 1
    Row 2, Column 2
    Row 2, Column 3
    Row 3, Column 1
    Row 3, Column 2
    Row 3, Column 3
    

    For each row, the inner loop goes through all three columns.

    Nested loops are useful for grids, calendars, game boards and HTML tables. However, too many large nested loops can make a program slow.


    Mini Project 1: Build a Multiplication Table

    Let us create a multiplication table for the number 5.

    <!DOCTYPE html>
    <html>
    <head>
        <title>PHP Multiplication Table</title>
    </head>
    <body>
    
    <h1>5 Times Table</h1>
    
    <?php
    
    $table = 5;
    
    for ($number = 1; $number <= 12; $number++) {
        $answer = $table * $number;
    
        echo $table;
        echo " × ";
        echo $number;
        echo " = ";
        echo $answer;
        echo "<br>";
    }
    
    ?>
    
    </body>
    </html>
    

    The output begins with:

    5 × 1 = 5
    5 × 2 = 10
    5 × 3 = 15
    5 × 4 = 20
    

    It continues until:

    5 × 12 = 60
    

    Change:

    $table = 5;
    

    to:

    $table = 9;
    

    The program will create the 9 times table without requiring any other changes.


    Mini Project 2: Multiplication Table with HTML

    We can improve the project by displaying the results inside an HTML table.

    <!DOCTYPE html>
    <html>
    <head>
        <title>Multiplication Table</title>
    
        <style>
            body {
                font-family: Arial, sans-serif;
                padding: 30px;
            }
    
            table {
                border-collapse: collapse;
                width: 400px;
            }
    
            th,
            td {
                border: 1px solid #333;
                padding: 10px;
                text-align: center;
            }
    
            th {
                background-color: #f2f2f2;
            }
        </style>
    </head>
    <body>
    
    <?php $table = 7; ?>
    
    <h1><?php echo $table; ?> Times Table</h1>
    
    <table>
        <tr>
            <th>Calculation</th>
            <th>Answer</th>
        </tr>
    
        <?php for ($number = 1; $number <= 12; $number++): ?>
            <tr>
                <td>
                    <?php
                    echo $table . " × " . $number;
                    ?>
                </td>
    
                <td>
                    <?php
                    echo $table * $number;
                    ?>
                </td>
            </tr>
        <?php endfor; ?>
    </table>
    
    </body>
    </html>
    

    Save the file as:

    multiplication-table.php
    

    If you are using PHP’s built-in development server, open the project folder in a terminal and run:

    php -S localhost:8000
    

    Then visit:

    http://localhost:8000/multiplication-table.php
    

    Mini Project 3: Simple Game Leaderboard

    This project uses an associative array and a foreach loop.

    <!DOCTYPE html>
    <html>
    <head>
        <title>Game Leaderboard</title>
    </head>
    <body>
    
    <h1>Game Leaderboard</h1>
    
    <?php
    
    $players = [
        "Aiman" => 950,
        "Mei Ling" => 870,
        "Kumar" => 820,
        "Sarah" => 760
    ];
    
    $position = 1;
    
    ?>
    
    <ol>
        <?php foreach ($players as $name => $score): ?>
            <li>
                <?php
                echo $name;
                echo " — ";
                echo $score;
                echo " points";
                ?>
            </li>
    
            <?php $position++; ?>
        <?php endforeach; ?>
    </ol>
    
    </body>
    </html>
    

    The browser displays a numbered list of players and their scores.

    The program can display more players simply by adding them to the array.


    Common PHP Loop Mistakes

    1. Forgetting to Change the Counter

    Incorrect:

    <?php
    
    $number = 1;
    
    while ($number <= 10) {
        echo $number;
    }
    

    The value never changes, causing an infinite loop.

    Correct:

    <?php
    
    $number = 1;
    
    while ($number <= 10) {
        echo $number . "<br>";
        $number++;
    }
    

    2. Using the Wrong Comparison

    This loop:

    for ($number = 1; $number < 5; $number++) {
        echo $number . "<br>";
    }
    

    stops at 4 because the condition requires the number to be lower than 5.

    To include 5, use:

    $number <= 5
    

    Remember:

    OperatorMeaning
    <Less than
    <=Less than or equal to
    >Greater than
    >=Greater than or equal to
    ===Exactly equal in value and type
    !==Not exactly equal

    3. Adding a Semicolon After the Loop

    Incorrect:

    for ($number = 1; $number <= 5; $number++); {
        echo $number;
    }
    

    The semicolon ends the loop too early.

    Correct:

    for ($number = 1; $number <= 5; $number++) {
        echo $number;
    }
    

    4. Forgetting the Curly Braces

    PHP allows some one-line loops without curly braces, but beginners should keep them.

    Recommended:

    foreach ($animals as $animal) {
        echo $animal;
    }
    

    Curly braces make the beginning and end of the repeated code clear.


    5. Changing an Array Unexpectedly

    Be careful when changing an array while looping through it. Removing or adding elements during a loop can produce confusing results.

    For beginner projects, prepare the array first and then use foreach to display or process its values.


    Practice Exercises

    Try completing these exercises without copying the final answer immediately.

    Exercise 1: Count from 1 to 20

    Create a for loop that displays the numbers from 1 to 20.

    Expected result:

    1 2 3 4 5 ... 20
    

    Exercise 2: Count Backwards

    Create a loop that counts from 10 down to 1 and then displays:

    Happy New Year!
    

    Exercise 3: Even Numbers

    Display all the even numbers between 2 and 20.

    Exercise 4: Favourite Foods

    Create an array containing five foods. Use foreach to display each food inside an HTML list.

    Exercise 5: Total the Scores

    Use this array:

    $scores = [10, 20, 15, 25, 30];
    

    Create a loop that adds all the scores together.

    Hint:

    $total = 0;
    

    Exercise 6: Multiplication Table

    Change the multiplication-table project so that it displays the 12 times table.


    Exercise Answers

    Answer 1

    <?php
    
    for ($number = 1; $number <= 20; $number++) {
        echo $number . " ";
    }
    

    Answer 2

    <?php
    
    for ($number = 10; $number >= 1; $number--) {
        echo $number . "<br>";
    }
    
    echo "Happy New Year!";
    

    Answer 3

    <?php
    
    for ($number = 2; $number <= 20; $number += 2) {
        echo $number . "<br>";
    }
    

    Answer 4

    <?php
    
    $foods = [
        "Nasi lemak",
        "Chicken rice",
        "Roti canai",
        "Fried noodles",
        "Pizza"
    ];
    
    echo "<ul>";
    
    foreach ($foods as $food) {
        echo "<li>" . $food . "</li>";
    }
    
    echo "</ul>";
    

    Answer 5

    <?php
    
    $scores = [10, 20, 15, 25, 30];
    
    $total = 0;
    
    foreach ($scores as $score) {
        $total += $score;
    }
    
    echo "Total score: " . $total;
    

    The output is:

    Total score: 100
    

    Answer 6

    Change the table number:

    $table = 12;
    

    The existing loop can remain the same.


    PHP Loop Cheat Sheet

    // for loop
    for ($number = 1; $number <= 5; $number++) {
        echo $number;
    }
    
    // while loop
    $number = 1;
    
    while ($number <= 5) {
        echo $number;
        $number++;
    }
    
    // do...while loop
    $number = 1;
    
    do {
        echo $number;
        $number++;
    } while ($number <= 5);
    
    // foreach loop
    $colours = ["Red", "Blue", "Green"];
    
    foreach ($colours as $colour) {
        echo $colour;
    }
    
    // foreach with keys and values
    $student = [
        "name" => "Ali",
        "age" => 13
    ];
    
    foreach ($student as $key => $value) {
        echo $key . ": " . $value;
    }
    

    Frequently Asked Questions

    Which PHP loop should a beginner learn first?

    Start with the for loop because its starting value, condition and counter are shown together. After that, learn while and foreach.

    When should I use foreach?

    Use foreach when you want to process every item in an array. It is normally easier than using a numbered for loop for this purpose.

    What is an infinite loop?

    An infinite loop is a loop whose stopping condition never becomes false. It continues until the program is stopped or PHP reaches a configured resource limit.

    What is the difference between while and do…while?

    A while loop checks its condition before running. A do...while loop runs once before checking its condition.

    Therefore, a do...while loop always runs at least once.

    What does $number++ mean?

    It increases the value of $number by one.

    $number++;
    

    is similar to:

    $number = $number + 1;
    

    Can PHP loops create HTML?

    Yes. PHP loops are commonly used to generate HTML lists, tables, menus, cards and other repeated webpage elements.

    Can a loop contain an if statement?

    Yes. Conditions are frequently placed inside loops.

    for ($number = 1; $number <= 10; $number++) {
        if ($number % 2 === 0) {
            echo $number . " is even.<br>";
        }
    }
    

    Can one loop be placed inside another?

    Yes. This is called a nested loop. It is useful for tables and grids, but large nested loops can require more processing.


    Final Summary

    A loop allows PHP to repeat instructions without duplicating code.

    In this tutorial, we learned:

    • for repeats code using a counter.
    • while continues while a condition is true.
    • do...while runs at least once.
    • foreach processes the items in an array.
    • break stops a loop.
    • continue skips the current round.
    • A loop must eventually reach its stopping condition.
    • Loops can generate HTML lists and tables.
    • Nested loops can create rows, columns and grids.

    The best way to understand loops is to edit the examples. Change the starting number, stopping condition and counter, then observe how the output changes.

  • Best Japanese Watches to Buy in Japan in 2026: Seiko, Citizen, Casio, Orient and Grand Seiko Guide for Malaysians

    Japan is one of the best destinations for buying watches, particularly when you want a Japanese domestic-market model, Japan-exclusive colour, wider product selection or a watch made in Japan.

    Japanese watch brands cover almost every budget:

    • Affordable Casio digital watches
    • Tough G-Shock models
    • Solar-powered Citizen watches
    • Mechanical Orient watches
    • Seiko dive and dress watches
    • Premium titanium watches
    • GPS-controlled travel watches
    • Grand Seiko luxury watches

    However, a watch is not automatically cheaper simply because it is purchased in Japan.

    Malaysian travellers should compare the exact model number, warranty coverage, tax-free eligibility, credit-card conversion cost and Malaysia retail price before buying.

    This guide covers the best Japanese watches to buy in 2026, realistic prices, movement differences, warranty issues, tax-free procedures and important checks for using a Japanese domestic-market watch in Malaysia.

    Exchange Rate Used

    ¥100 = RM3.00

    Therefore:

    • ¥5,000 ≈ RM150
    • ¥10,000 ≈ RM300
    • ¥20,000 ≈ RM600
    • ¥50,000 ≈ RM1,500
    • ¥100,000 ≈ RM3,000
    • ¥200,000 ≈ RM6,000
    • ¥500,000 ≈ RM15,000
    • ¥1,000,000 ≈ RM30,000

    Prices are estimates unless a specific current official price is stated. Store discounts, tax-free treatment, limited availability and exchange rates can change the final cost.


    Quick Answer

    The best Japanese watch brands to consider are:

    Brand or CollectionBest ForTypical Japan Budget
    Casio CollectionAffordable everyday watchesRM60–450
    G-ShockDurability, sports and outdoor useRM300–3,000+
    EdificeAffordable metal sports watchesRM300–1,500
    OceanusPremium solar titanium watchesRM2,400–9,000+
    Seiko SelectionPractical quartz and solar watchesRM600–2,100
    Seiko PresageMechanical dress watchesRM1,500–7,500+
    Seiko ProspexDive, field and sports watchesRM2,100–16,500+
    Seiko AstronGPS solar travel watchesRM6,000–12,000+
    Citizen Eco-DriveLow-maintenance solar watchesRM600–3,000
    Citizen AttesaTitanium, radio and GPS solar watchesRM3,000–12,000
    Citizen Series 8Modern mechanical watchesRM4,500–7,500
    OrientAffordable mechanical watchesRM600–2,100
    Orient StarHigher-grade Japanese mechanical watchesRM1,800–6,000+
    Grand SeikoPremium quartz, mechanical and Spring DriveRM10,000–90,000+

    For most Malaysian travellers:

    • Affordable watch: RM200–600
    • Good Japanese everyday watch: RM600–1,500
    • Mechanical or premium solar watch: RM1,500–4,000
    • Premium Japanese watch: RM4,000–10,000
    • Grand Seiko: RM10,000–30,000 for many standard models
    • High-end or limited watch: RM30,000 and above

    Best Watch by Type

    Best Affordable Digital Watch

    Casio Collection

    Choose this when you want:

    • Low price
    • Long battery life
    • Alarm
    • Stopwatch
    • World time
    • Simple daily use

    Best Tough Watch

    G-Shock

    Suitable for:

    • Outdoor work
    • Construction
    • Exercise
    • Travel
    • Water exposure
    • Motorcycling

    Best Low-Maintenance Analogue Watch

    Citizen Eco-Drive

    Suitable for someone who wants an analogue watch without regularly changing a conventional battery.

    Best Affordable Mechanical Watch

    Orient Bambino or Orient Mako

    Suitable for buyers who want a traditional mechanical movement at a relatively accessible price.

    Best Japanese Dress Watch

    Seiko Presage

    Particularly strong when you want:

    • Decorative dial
    • Mechanical movement
    • Japanese craftsmanship
    • Office or formal styling

    Best Japanese Dive Watch

    Seiko Prospex

    Choose according to wrist size, movement and actual diving requirements rather than appearance alone.

    Best Premium Lightweight Watch

    Citizen Attesa or Casio Oceanus

    Many models combine titanium, solar charging and automatic time correction.

    Best Frequent-Traveller Watch

    Seiko Astron GPS Solar

    A GPS-controlled model can update its time zone using satellite signals when reception conditions are suitable.

    Best Luxury Japanese Watch

    Grand Seiko

    Grand Seiko offers high-accuracy quartz, mechanical movements and Spring Drive watches with premium case and dial finishing.


    Understand the Main Watch Movements

    Before comparing brands, decide which movement suits you.


    Quartz Watches

    A conventional quartz watch uses a battery and electronic oscillator.

    Advantages

    • Accurate
    • Affordable
    • Thin
    • Low maintenance
    • Can run for several years between battery changes
    • Suitable for occasional wear

    Disadvantages

    • Requires eventual battery replacement
    • Some watch enthusiasts prefer mechanical movements
    • Battery replacement should be performed carefully on water-resistant watches

    Best For

    • Practical users
    • Office wear
    • Gifts
    • Travellers who do not want to reset a watch frequently

    Solar Watches

    Solar watches convert light into electrical energy and store it in a rechargeable cell.

    Examples include:

    • Citizen Eco-Drive
    • Casio Tough Solar
    • Seiko Solar

    Advantages

    • No frequent conventional battery changes
    • Accurate quartz timekeeping
    • Suitable for regular use
    • Available in affordable and premium models

    Disadvantages

    • The rechargeable cell will not last forever.
    • Long storage in darkness can discharge the watch.
    • Some models require strong light to recover from deep discharge.
    • Repairs may cost more than replacing an ordinary quartz battery.

    Best Practice

    Do not leave a solar watch inside a closed drawer for several months.

    Store it where the dial receives regular indirect light, while avoiding excessive heat.


    Mechanical Watches

    Mechanical watches operate using a wound mainspring.

    They may be:

    • Manual winding
    • Automatic
    • Automatic with manual winding

    Advantages

    • Traditional watchmaking
    • No electronic battery
    • Visible mechanical movement on some models
    • Strong enthusiast and collector appeal

    Disadvantages

    • Less accurate than quartz
    • Requires periodic servicing
    • May stop when not worn
    • Sensitive to magnetism and impact
    • More expensive to maintain

    A mechanical watch gaining or losing several seconds per day may still be operating within its published specification.

    Do not expect an affordable automatic watch to match quartz accuracy.


    Spring Drive

    Spring Drive is strongly associated with Grand Seiko.

    It uses a mainspring for energy but regulates time through an electronic system rather than a traditional mechanical escapement.

    Advantages

    • Smooth-gliding seconds hand
    • High accuracy compared with many mechanical watches
    • Traditional mainspring energy
    • Distinctive Japanese technology

    Disadvantages

    • High purchase price
    • Specialist servicing
    • Fewer independent repair options
    • Service may require sending the watch to an authorised centre

    Spring Drive is most suitable for someone who values the technology and intends to keep the watch long term.


    Radio-Controlled Watches

    Radio-controlled watches receive standard time signals from terrestrial transmitters.

    Casio’s Multi Band 6 system receives signals from stations serving Japan, China, North America, the United Kingdom and Germany. Malaysia is not listed as one of the supported transmission regions.

    What This Means in Malaysia

    A Japanese radio-controlled watch will still operate as a normal quartz or solar watch in Malaysia.

    However, it may not automatically synchronise using terrestrial radio signals.

    Depending on the model, you may need to use:

    • Manual time setting
    • Bluetooth synchronisation
    • GPS synchronisation
    • A successful radio update while travelling in a supported country

    Do not pay extra for radio control unless the watch also provides other features you value.


    Bluetooth Watches

    Some Casio, Citizen and Seiko watches connect to a smartphone.

    Possible functions include:

    • Automatic time correction
    • World-time adjustment
    • Phone finder
    • Alarm setting
    • Watch configuration
    • Activity data

    Important Checks

    Before buying:

    1. Confirm the application is available in the Malaysian app store.
    2. Confirm your phone operating system is supported.
    3. Check whether the watch needs a Japanese-region account.
    4. Confirm Bluetooth use is supported in Malaysia.
    5. Consider what happens if the manufacturer eventually ends application support.

    Citizen warns that Bluetooth-equipped watches are subject to the radio regulations and service conditions of the country where they are used.

    A watch should still perform its essential timekeeping functions without depending completely on an application.


    GPS Solar Watches

    GPS watches receive timing and location information from satellites.

    They can be useful for travellers moving across time zones.

    Advantages

    • Global time-zone adjustment
    • Does not depend on terrestrial radio coverage
    • Solar charging on many models
    • Useful for frequent international travel

    Disadvantages

    • Expensive
    • Larger case on many models
    • GPS reception requires suitable sky visibility
    • More complex movement
    • Higher servicing cost

    A GPS model is unnecessary if you travel internationally only once every few years and are comfortable setting the time manually.


    1. Casio Collection

    Casio Collection includes practical digital and analogue watches at accessible prices.

    Popular styles include:

    • Basic digital watches
    • Retro metal watches
    • Analogue everyday watches
    • World-time watches
    • Calculator watches
    • Compact women’s watches

    Casio describes the collection as functional, dependable everyday watches for different users and age groups.

    Estimated Price

    ¥2,000–15,000

    Approximately RM60–450.

    Popular Types

    • F-91W-style digital watches
    • A158 and A159 metal-look watches
    • World Time watches
    • Analogue three-hand watches
    • Data Bank models
    • Casiotron-inspired models

    Best For

    • Affordable gifts
    • Students
    • Travel backup watch
    • Retro styling
    • Low-maintenance daily wear

    Is It Worth Buying in Japan?

    It can be worthwhile when:

    • The colour is unavailable in Malaysia.
    • You find a Japan-only model.
    • The price is substantially lower.
    • You want a compact gift.

    For globally common models, the saving may be too small to justify a special shopping trip.


    2. G-Shock

    G-Shock is the most obvious choice for a durable Japanese sports watch.

    The range includes:

    • Basic resin watches
    • Digital squares
    • Analogue-digital models
    • Solar watches
    • Bluetooth models
    • Full-metal watches
    • MT-G
    • MR-G
    • Fitness-oriented G-Squad models

    Casio’s 2026 Japanese range continues to cover affordable resin models through premium metal collections such as MT-G and MR-G.

    Estimated Price

    G-Shock TypeJPYApprox. RM
    Basic resin model¥10,000–18,000RM300–540
    Solar or Bluetooth model¥18,000–40,000RM540–1,200
    Full-metal G-Shock¥60,000–100,000RM1,800–3,000
    MT-G¥120,000–200,000RM3,600–6,000
    MR-G¥300,000–800,000+RM9,000–24,000+

    Best Value G-Shock Types

    Basic Square

    Good for:

    • Work
    • Exercise
    • Travel
    • Small or medium wrists
    • Buyers who want the classic G-Shock design

    Tough Solar Square

    Adds solar charging and may include radio or Bluetooth time correction.

    Full-Metal Square

    Provides:

    • Metal case and bracelet
    • Premium appearance
    • Bluetooth
    • Solar power
    • Strong nostalgic design

    It is heavier and significantly more expensive than a resin model.

    G-Shock “CasiOak”

    These models use an octagonal case design and are available in resin, metal-covered and full-metal configurations.

    What to Check

    • Case diameter
    • Lug-to-lug length
    • Display readability
    • Positive or negative LCD
    • Solar charging
    • Bluetooth support
    • Band comfort
    • Malaysia warranty
    • Exact regional model code

    Negative displays often look attractive in photographs but can be harder to read indoors.


    3. Casio Edifice

    Edifice focuses on sporty analogue watches, chronographs and connected metal watches.

    Its current technology includes combinations of:

    • Chronograph functions
    • World time
    • Smartphone link
    • Solar charging
    • Radio-controlled timekeeping
    • Motorsport-inspired design

    Estimated Price

    ¥10,000–50,000

    Approximately RM300–1,500.

    Premium or limited models may cost more.

    Best For

    • Office wear
    • Motorsport styling
    • Buyers who want a metal Casio
    • Affordable chronographs
    • Solar and Bluetooth functions

    Buying Advice

    Some Edifice watches have large, busy dials.

    Check:

    • Whether you can read the subdials easily
    • Case thickness
    • Bracelet adjustment
    • Whether the Bluetooth functions are genuinely useful
    • Whether the same model is already discounted in Malaysia

    4. Casio Oceanus

    Oceanus is Casio’s premium metal-watch collection.

    It commonly combines:

    • Titanium
    • Solar charging
    • Radio-controlled time
    • Bluetooth
    • Sapphire crystal
    • High-quality finishing
    • Blue dial or bezel details

    Current Japanese listings show examples such as the Classic line around ¥115,500 and Manta models around ¥165,000 and above. A 2026 Oceanus OCW-S7000F is officially priced at ¥220,000, approximately RM6,600, with titanium construction, solar charging, Bluetooth and Multi Band 6.

    Estimated Price

    Oceanus LevelJPYApprox. RM
    Entry or Classic¥80,000–130,000RM2,400–3,900
    Manta¥150,000–250,000RM4,500–7,500
    Limited or premium model¥250,000–500,000+RM7,500–15,000+

    Best For

    • Lightweight titanium
    • Smart office wear
    • Solar convenience
    • Frequent travel
    • Buyers wanting premium Casio technology

    Malaysia Consideration

    The terrestrial radio function may not synchronise in Malaysia.

    A model with Bluetooth is usually more practical than a radio-only model for Malaysian ownership.


    5. Seiko Selection

    Seiko Selection focuses on practical watches built around Seiko’s basic timekeeping and design principles.

    The collection may include:

    • Quartz watches
    • Solar watches
    • Radio-controlled watches
    • Simple dress watches
    • Collaboration models

    A current 2026 limited Seiko Selection model is officially listed at ¥49,500, approximately RM1,485.

    Estimated Price

    ¥20,000–70,000

    Approximately RM600–2,100.

    Best For

    • Everyday Japanese domestic-market watches
    • Practical gifts
    • Office wear
    • Solar and radio-controlled models
    • Buyers who do not need a mechanical movement

    Important JDM Check

    Some Seiko Selection models are intended mainly for Japan.

    Check:

    • Warranty
    • English manual availability
    • Radio-signal usefulness in Malaysia
    • Day-wheel language
    • Model code
    • Replacement bracelet and strap availability

    6. Seiko Presage

    Seiko Presage combines mechanical watchmaking with Japanese-inspired design.

    The main ranges include:

    • Cocktail Time
    • Style60’s
    • Japanese Garden
    • Classic Series
    • Sharp Edged Series
    • Craftsmanship models

    Current Japanese official prices range from approximately ¥57,200 for selected established models to more than ¥200,000 for enamel, porcelain and other craftsmanship pieces. New 2026 Presage models include ¥68,200 standard models, ¥132,000 Classic Series watches and a ¥242,000 Arita porcelain limited edition.

    Estimated Price

    Presage TypeJPYApprox. RM
    Entry Presage¥50,000–80,000RM1,500–2,400
    Cocktail Time or Style60’s¥55,000–100,000RM1,650–3,000
    Classic or Sharp Edged¥100,000–180,000RM3,000–5,400
    Craftsmanship model¥150,000–300,000+RM4,500–9,000+

    Best Presage Choices

    Cocktail Time

    Known for decorative dials inspired by cocktails.

    Best for:

    • Office wear
    • Dinner
    • Formal occasions
    • Buyers wanting a striking dial

    Classic Series

    Uses softer case forms, textured dials and multi-link bracelets inspired by Japanese materials and aesthetics. Selected models use approximately 72-hour 6R-series movements.

    Craftsmanship Models

    May use:

    • Enamel
    • Arita porcelain
    • Urushi lacquer
    • Shippo enamel

    These cost more and may have limited production.

    Main Weaknesses

    Depending on the model:

    • Limited water resistance
    • Thick case
    • Hardlex instead of sapphire on some lower models
    • Accuracy typical of a mid-range mechanical movement
    • Expensive servicing compared with quartz

    Do not buy a dress-focused Presage if you need a swimming or rough-use watch.


    7. Seiko Prospex

    Prospex covers professional and sports-oriented watches.

    Categories include:

    • Diver watches
    • Field watches
    • Alpinist
    • Speedtimer chronographs
    • Marine Master
    • Solar watches
    • Mechanical GMT watches

    Current Japanese official examples include a solar Prospex at ¥71,500, mechanical models at ¥126,500–143,000, GMT divers at ¥247,500 and Marine Master models above ¥400,000.

    Estimated Price

    Prospex TypeJPYApprox. RM
    Entry solar Prospex¥60,000–100,000RM1,800–3,000
    Mechanical diver or Alpinist¥100,000–180,000RM3,000–5,400
    GMT or heritage diver¥180,000–300,000RM5,400–9,000
    Marine Master¥350,000–550,000+RM10,500–16,500+

    Best Prospex Choices

    Solar Diver

    Good for buyers who want:

    • Strong water resistance
    • Low maintenance
    • Dive-watch appearance
    • Quartz accuracy

    Mechanical Diver

    Good for watch enthusiasts who want traditional movement construction.

    Alpinist

    Suitable for:

    • Field-watch styling
    • Outdoor-inspired design
    • Everyday use
    • Buyers wanting a less bulky alternative to a full diver

    Speedtimer

    Suitable for buyers wanting a chronograph with historic Seiko styling.

    What to Check

    • ISO diver certification where relevant
    • Screw-down crown
    • Water-resistance rating
    • Case diameter
    • Lug-to-lug length
    • Bracelet clasp
    • Bezel alignment
    • Movement accuracy
    • Whether the watch is comfortable on your wrist

    A 44 mm diver can feel much larger than a 44 mm digital G-Shock because of case shape and bracelet weight.


    8. Seiko Astron

    Astron is Seiko’s GPS solar collection.

    The range is designed for travellers who want automatic time-zone adjustment using satellite reception.

    Current 2026 Astron models include new and limited GPS solar watches, with international listed prices generally equivalent to the premium Seiko segment.

    Estimated Japan Price

    ¥200,000–450,000

    Approximately RM6,000–13,500.

    Limited models may cost more.

    Best For

    • Frequent international travel
    • Quartz accuracy
    • Solar charging
    • Automatic time-zone adjustment
    • Buyers who prefer modern technology

    Consider Before Buying

    Astron watches can be:

    • Large
    • Visually complex
    • Expensive to repair
    • More technological than traditional

    Check whether you genuinely need GPS time-zone correction.

    For most travellers, manually changing the hour hand a few times per year is not difficult.


    9. Citizen Eco-Drive

    Eco-Drive is Citizen’s solar-charging technology.

    Citizen offers Eco-Drive watches across many categories:

    • Affordable dress watches
    • Dive watches
    • Titanium watches
    • Radio-controlled watches
    • GPS watches
    • Women’s watches
    • Premium models

    Estimated Price

    ¥20,000–100,000

    Approximately RM600–3,000 for many mainstream models.

    Premium lines cost more.

    Best For

    • Low maintenance
    • Everyday office wear
    • Gifts
    • Buyers who do not want an automatic watch
    • People who rotate between several watches

    What to Check

    • Case material
    • Sapphire or mineral crystal
    • Water resistance
    • Remaining charge indication
    • Radio, GPS or Bluetooth compatibility
    • International warranty

    Citizen advises buyers who will use a watch outside its domestic warranty area to ask the authorised retailer to issue an international warranty.

    From October 1, 2025, eligible Citizen watches bought from authorised retailers can receive a three-year international guarantee.


    10. Citizen Attesa

    Attesa is one of Citizen’s strongest premium collections for Malaysian travellers.

    It commonly combines:

    • Super Titanium
    • Eco-Drive
    • Radio control
    • GPS on selected models
    • World time
    • Sapphire crystal
    • Surface-hardening treatment

    Citizen states that its Super Titanium is approximately 40% lighter than stainless steel and uses surface-hardening technology designed to provide significantly greater hardness.

    Current Japanese Attesa prices include examples around ¥159,500, ¥192,500, ¥363,000 and ¥385,000.

    Estimated Price

    Attesa TypeJPYApprox. RM
    Entry radio solar¥100,000–170,000RM3,000–5,100
    Titanium chronograph¥150,000–220,000RM4,500–6,600
    GPS satellite model¥250,000–400,000RM7,500–12,000

    Best For

    • Lightweight daily wear
    • Frequent travellers
    • Solar convenience
    • Buyers who want Japanese titanium technology
    • Office and smart-casual use

    Malaysian Buying Advice

    A radio-only Attesa may not synchronise in Malaysia.

    For easier automatic time correction, consider:

    • GPS
    • Bluetooth
    • Manual time setting

    Do not assume every Attesa includes GPS simply because it has a complex dial.


    11. Citizen Series 8

    Series 8 is Citizen’s modern mechanical-watch collection.

    It generally offers:

    • Mechanical movements
    • Integrated or semi-integrated bracelet styling
    • Anti-magnetic performance
    • Modern geometric cases
    • Sporty luxury design

    Current Japanese Series 8 models are officially listed from approximately ¥159,500 to ¥242,000 for several mainstream references.

    Estimated Price

    ¥150,000–250,000

    Approximately RM4,500–7,500.

    Best For

    • Modern mechanical-watch styling
    • Integrated bracelet designs
    • Buyers considering Seiko Presage or entry luxury watches
    • Citizen enthusiasts who want mechanical rather than Eco-Drive

    Main Consideration

    Series 8 competes in a crowded price range.

    At RM5,000–8,000, compare it with:

    • Seiko Presage
    • Seiko Prospex
    • Orient Star
    • Swiss mechanical watches
    • Used Grand Seiko quartz models

    Buy based on fit, design and movement preference rather than brand loyalty alone.


    12. Orient

    Orient is known for affordable mechanical watches.

    Popular families include:

    • Bambino
    • Mako
    • Kamasu
    • Contemporary
    • Classic
    • Sports models
    • Sun and Moon

    Orient continued to announce new 2026 products, while its core categories remain focused on mechanical watches and accessible sports models.

    Estimated Price

    ¥20,000–70,000

    Approximately RM600–2,100.

    Best Orient Choices

    Orient Bambino

    Suitable for:

    • Office wear
    • Weddings
    • Formal occasions
    • First mechanical watch

    Check:

    • Case diameter
    • Crystal type
    • Water resistance
    • Strap quality
    • Generation and model number

    Orient Mako or Kamasu

    Suitable for:

    • Sporty everyday wear
    • Dive-watch design
    • Better water resistance
    • Buyers who want an affordable automatic sports watch

    Is Orient Cheaper in Japan?

    Not always.

    Orient watches are frequently sold through online retailers outside Japan at competitive prices.

    Japan may still be worthwhile for:

    • Domestic references
    • Limited colours
    • Authorised-store warranty
    • Orient Star models
    • Models unavailable in Malaysia

    13. Orient Star

    Orient Star is positioned above standard Orient.

    It offers:

    • Made-in-Japan mechanical watches
    • Power-reserve indicators
    • Semi-skeleton designs
    • Moon-phase watches
    • Dive watches
    • Higher-grade finishing

    Orient promotes Orient Star around Made-in-Japan quality and its long mechanical-watch history.

    Estimated Price

    ¥60,000–200,000+

    Approximately RM1,800–6,000+.

    Best For

    • Mechanical-watch enthusiasts
    • Buyers wanting Japanese manufacturing
    • People comparing Presage and Series 8
    • Buyers who want visible movement details

    Buying Advice

    Orient Star’s power-reserve indicator is useful, but some dials can become visually busy.

    Choose the design you will enjoy after the novelty of the mechanical display wears off.


    14. Grand Seiko

    Grand Seiko is Japan’s leading mainstream luxury-watch brand.

    Its movement categories include:

    • 9F quartz
    • Mechanical
    • Hi-Beat mechanical
    • Spring Drive
    • Manual-wind Spring Drive
    • High-end Masterpiece movements

    Current official Japanese prices include the SBGX261 quartz model at ¥363,000, approximately RM10,890; the SBGR261 mechanical model at ¥638,000, approximately RM19,140; and the Spring Drive SBGA211 at ¥902,000, approximately RM27,060.

    Grand Seiko also offers extremely high-priced artistic and limited pieces, including models costing several million yen.

    Estimated Price

    Grand Seiko TypeJPYApprox. RM
    9F quartz¥360,000–600,000RM10,800–18,000
    Entry mechanical¥600,000–900,000RM18,000–27,000
    Spring Drive¥680,000–1,200,000RM20,400–36,000
    Evolution 9¥1,000,000–2,000,000RM30,000–60,000
    Limited or Masterpiece¥2,000,000–38,500,000+RM60,000–1,155,000+

    Best Grand Seiko Choices

    9F Quartz

    Good for:

    • Exceptional quartz accuracy
    • Low daily maintenance
    • Thin case
    • Office use
    • Buyers who value finishing over mechanical complexity

    A Grand Seiko quartz watch is not merely an ordinary battery watch with a luxury dial. The movement, case finishing and quality control are part of the value.

    Mechanical

    Good for:

    • Traditional watchmaking
    • Enthusiast appeal
    • Visible movement architecture
    • Long-term ownership

    Spring Drive

    Good for:

    • Smooth seconds-hand movement
    • High accuracy
    • Distinctive Japanese engineering
    • Buyers who want something unavailable from most Swiss brands

    Grand Seiko Warranty

    Grand Seiko provides a five-year worldwide warranty for watches purchased through authorised retail partners.

    The warranty must be activated or correctly registered through the applicable system.

    Japan vs Malaysia Price

    Do not assume Japan is automatically cheaper.

    For example, the current Malaysian Grand Seiko site lists the SBGA211 at RM27,800, while the Japanese official price of ¥902,000 converts to approximately RM27,060 using this guide’s exchange rate before considering tax-free savings, card charges or local promotions.

    The potential saving may therefore be much smaller than expected.

    Compare:

    • Exact reference
    • Tax-free price
    • Malaysian authorised-dealer discount
    • Warranty registration
    • Credit-card conversion
    • Future servicing
    • Insurance

    Best Watches Under RM300

    RM300 is approximately ¥10,000.

    Good options include:

    • Casio digital watches
    • Casio retro metal watches
    • Basic analogue Casio
    • Entry Q&Q solar watches
    • Discounted quartz watches
    • Simple Alba models

    Be careful with domestic-only Alba or licensed-brand warranties. Seiko states that Alba and selected licensed products may carry Japan-only warranty coverage.


    Best Watches Under RM600

    RM600 is approximately ¥20,000.

    Good options include:

    • Entry G-Shock
    • Casio Edifice
    • Better Casio digital models
    • Citizen quartz
    • Entry Citizen Eco-Drive on sale
    • Affordable Seiko quartz
    • Entry Orient mechanical watches

    Best Watches Under RM1,000

    RM1,000 is approximately ¥33,333.

    Good options include:

    • Solar G-Shock
    • Citizen Eco-Drive
    • Orient Bambino
    • Orient Mako
    • Seiko solar watches
    • Edifice Bluetooth models
    • Better Casio Collection watches

    Best Watches Under RM2,000

    RM2,000 is approximately ¥66,667.

    Good options include:

    • Seiko Presage entry models
    • Seiko Selection
    • Orient Star entry models
    • Mid-range Citizen Eco-Drive
    • G-Shock metal-covered models
    • Higher-grade Edifice
    • Japanese domestic-market solar watches

    Best Watches Under RM5,000

    RM5,000 is approximately ¥166,667.

    Good options include:

    • Seiko Presage Classic
    • Seiko Prospex mechanical
    • Citizen Attesa
    • Citizen Series 8 entry models
    • Orient Star
    • Casio Oceanus Classic
    • Full-metal G-Shock

    This is one of the most competitive Japanese watch price ranges.

    Take time to compare movement, case finishing, bracelet quality and warranty.


    Best Watches Under RM10,000

    RM10,000 is approximately ¥333,333.

    Good options include:

    • Premium Seiko Prospex
    • Seiko Astron
    • Citizen Attesa GPS
    • Oceanus Manta
    • Premium Series 8
    • High-end Orient Star
    • Used Grand Seiko
    • Premium G-Shock MT-G

    At this price, do not purchase impulsively during the final night of your trip.


    Example RM1,000 Watch Budget

    RM1,000 is approximately ¥33,333.

    PurchaseJPYApprox. RM
    Entry mechanical or solar watch¥28,000RM840
    Replacement strap¥2,500RM75
    Travel case¥1,500RM45
    Remaining budget¥1,300RM39
    Total¥33,300RM999

    Example RM3,000 Watch Budget

    RM3,000 is approximately ¥100,000.

    Possible options include:

    • Presage plus strap
    • Solar Prospex
    • Citizen titanium Eco-Drive
    • Premium G-Shock
    • Oceanus entry model on promotion
    • Orient Star

    Example:

    PurchaseJPYApprox. RM
    Mechanical Japanese watch¥85,000RM2,550
    Leather strap¥7,000RM210
    Travel case¥3,000RM90
    Remaining budget¥5,000RM150
    Total¥100,000RM3,000

    Example RM6,000 Watch Budget

    RM6,000 is approximately ¥200,000.

    Possible options include:

    • Citizen Attesa
    • Citizen Series 8
    • Presage Craftsmanship
    • Prospex mechanical diver
    • Oceanus Manta
    • Full-metal G-Shock plus another affordable watch

    Example:

    PurchaseJPYApprox. RM
    Premium Japanese watch¥180,000RM5,400
    Additional strap¥10,000RM300
    Travel insurance or accessory allowance¥10,000RM300
    Total¥200,000RM6,000

    Example RM15,000 Watch Budget

    RM15,000 is approximately ¥500,000.

    Possible choices include:

    • Grand Seiko 9F quartz
    • Premium Astron
    • High-end Attesa
    • G-Shock MR-G entry model
    • Premium Prospex
    • Two mid-range Japanese watches

    At this price, compare the Malaysian authorised-dealer offer before buying.


    Where to Buy Watches in Japan

    Brand Flagship Stores

    Best for:

    • Complete selection
    • New releases
    • Limited models
    • Correct warranty documentation
    • Bracelet adjustment
    • Product explanation

    Seiko operates specialist locations such as Seiko Dream Square, while Citizen maintains flagship stores carrying its major collections.

    Authorised Department-Store Counters

    Best for:

    • Premium service
    • Grand Seiko
    • Gift purchases
    • Correct warranty registration
    • Luxury shopping

    Large Electronics Retailers

    Best for:

    • Comparing Seiko, Citizen and Casio
    • Tourist discounts
    • Tax-free shopping
    • Point-of-sale price comparison
    • Broad mainstream selection

    Check whether a displayed discount requires:

    • Membership
    • Japanese payment method
    • Store points
    • Specific coupon
    • Tax-inclusive payment

    Specialist Watch Shops

    Best for:

    • Mechanical watches
    • Premium models
    • Pre-owned watches
    • Collector references
    • Detailed product knowledge

    Outlet Stores

    Best for:

    • Previous-season colours
    • Discontinued stock
    • Selected older models

    Check the manufacturing date and warranty start date.

    Second-Hand Shops

    Best for:

    • Discontinued watches
    • Vintage Seiko
    • Used Grand Seiko
    • Luxury watches
    • Rare Japanese references

    Used shopping requires more inspection and knowledge.


    Authorised Dealer vs Grey-Market Seller

    Authorised Dealer

    Advantages:

    • Manufacturer-backed warranty
    • Correct warranty activation
    • Reliable serial number
    • Proper accessories
    • Better after-sales support

    Possible disadvantage:

    • Higher price

    Grey-Market Seller

    A grey-market watch may be genuine but sold outside the manufacturer’s authorised distribution system.

    Possible issues include:

    • Store warranty only
    • Missing manufacturer warranty
    • Removed serial information
    • Old stock
    • Incorrect accessories
    • Previous handling
    • Limited international service

    A ¥20,000 saving can disappear if one repair is rejected.

    For expensive watches, prioritise an authorised retailer unless you understand the grey-market risk.


    Buying a Used Japanese Watch

    Before purchasing, check:

    1. Complete reference number
    2. Serial number
    3. Movement number
    4. Case condition
    5. Bracelet stretch
    6. Number of bracelet links
    7. Crown operation
    8. Date change
    9. Bezel alignment
    10. Crystal scratches
    11. Water-resistance test history
    12. Service record
    13. Original box
    14. Warranty card
    15. Seller return policy

    Mechanical Watch Timing

    Ask for:

    • Daily rate
    • Amplitude
    • Beat error
    • Timegrapher result

    A watch may appear accurate over five minutes while having mechanical problems.

    Water Resistance

    Do not assume a used dive watch remains water-resistant.

    Gaskets age, and previous opening or improper servicing can compromise the seal.

    Have it pressure-tested before swimming.


    Japan Domestic-Market Watch Issues

    A Japan domestic-market watch is often called a JDM watch.

    JDM models may offer:

    • Japan-only reference numbers
    • Exclusive dial colours
    • Japanese date display
    • Radio-controlled functions
    • Domestic warranty
    • Japanese packaging
    • Japanese-only instructions

    Check the Day Wheel

    Some day-date watches may display:

    • Japanese and English
    • Japanese only
    • English and another language

    Ask the staff to demonstrate the day display.

    Check the Manual

    Search for the movement or module number rather than only the watch reference.

    English instructions may be available online even when the printed booklet is Japanese.

    Check the Radio Function

    A radio-controlled JDM watch may not receive time signals in Malaysia.

    Check the Application

    Confirm any companion application works with a Malaysian Apple or Google account.

    Check the Warranty

    Ask directly:

    Is this international warranty valid in Malaysia?

    Do not accept only a verbal answer.

    Inspect the physical or digital warranty documentation.


    Watch Warranty Comparison

    Seiko

    Seiko extended the worldwide warranty on eligible Seiko watches purchased from authorised retailers to three years from October 1, 2024.

    Seiko states that Seiko-brand watches bought in Japan can receive overseas warranty support, although conditions may differ by country.

    Citizen

    Citizen introduced a three-year international guarantee for eligible purchases from authorised retailers from October 1, 2025.

    Ask the store to issue the international rather than Japan-limited warranty where applicable.

    Grand Seiko

    Grand Seiko provides five years of worldwide warranty coverage through authorised retailers.

    Casio

    Casio warranty arrangements can depend on model, retailer and region.

    Ask whether the Japanese warranty is accepted by Casio Malaysia.

    Keep:

    • Receipt
    • Warranty card
    • Store stamp
    • Model number
    • Serial number

    Bracelet Sizing

    Many Japanese stores can remove bracelet links during purchase.

    Before adjustment:

    1. Wear the watch for several minutes.
    2. Allow for Malaysia’s warmer climate.
    3. Check whether your wrist expands later in the day.
    4. Ask for micro-adjustment where available.
    5. Keep every removed link and pin.

    Do not leave spare links in Japan.

    Replacement links can be expensive and difficult to obtain.

    Titanium Bracelets

    Titanium is light but can require model-specific pins, collars or screws.

    Do not let an inexperienced shop resize an expensive titanium bracelet.


    Leather Straps in Malaysia

    Leather straps can deteriorate quickly in Malaysia’s heat and humidity.

    Sweat may cause:

    • Odour
    • Staining
    • Cracking
    • Lining damage
    • Premature failure

    For regular Malaysian use, consider:

    • Rubber
    • Silicone
    • Stainless-steel bracelet
    • Titanium bracelet
    • Fabric strap
    • Sweat-resistant leather

    Keep the original leather strap for formal occasions and use an aftermarket strap for daily wear.


    Watch Water-Resistance Guide

    MarkingPractical Interpretation
    No ratingKeep away from water
    3 bar / 30 mMinor splashes only
    5 bar / 50 mEveryday water exposure
    10 bar / 100 mSwimming may be acceptable if the manufacturer permits
    20 bar / 200 mStronger sports and water capability
    Diver’s 200 mDesigned to applicable diving-watch requirements

    A “100 m” marking does not mean you should operate buttons underwater.

    Water resistance also decreases as gaskets age.

    Follow the specific manufacturer’s instructions.


    Automatic Watch Accuracy

    Do not judge mechanical accuracy using quartz expectations.

    Factors affecting accuracy include:

    • Wrist position
    • Wearing duration
    • Power reserve
    • Magnetism
    • Temperature
    • Movement specification
    • Shock
    • Service condition

    Ask the seller for the published accuracy range.

    A mechanical watch running five seconds fast per day gains approximately:

    • 35 seconds per week
    • 150 seconds per month
    • About 30 minutes per year

    Periodic resetting is normal.


    Magnetism Risks

    Mechanical watches can be affected by:

    • Magnetic phone cases
    • Tablet covers
    • Speakers
    • Handbag clasps
    • Laptop magnets
    • Wireless chargers
    • Magnetic bracelets

    Symptoms may include:

    • Large sudden time gain
    • Large time loss
    • Unstable accuracy

    Some watches, including Citizen Series 8 and selected Grand Seiko models, are designed with enhanced magnetic resistance, but you should still follow the published limits.


    Tax-Free Watch Shopping Before November 1, 2026

    Before November 1, 2026, eligible temporary visitors generally use Japan’s existing tax-free procedure at participating stores.

    Common requirements include:

    • Original passport
    • Qualifying visitor status
    • Minimum eligible purchase
    • Same-day transaction at the participating store
    • Export of the goods from Japan

    Exact procedures can differ by retailer.

    Tax-Inclusive vs Pre-Tax Price

    Japan’s standard consumption tax is generally included in displayed retail prices.

    Example:

    • Tax-inclusive price: ¥110,000
    • Pre-tax value: ¥100,000
    • Tax portion: ¥10,000

    The theoretical saving is ¥10,000, approximately RM300.

    This is not the same as subtracting 10% from ¥110,000.

    Some stores or refund counters may charge a service fee, reducing the actual saving.


    Tax-Free Watch Shopping From November 1, 2026

    Japan changes to a refund-based tax-free system on November 1, 2026.

    Under the revised system:

    1. The store charges the tax-inclusive price.
    2. The traveller carries the watch out of Japan within the required period.
    3. Customs confirms export at departure.
    4. The tax-equivalent amount is refunded through the applicable retailer or refund process.

    The new system retains a minimum purchase value of ¥5,000 before tax and removes the former distinction between general and consumable goods.

    Watch Buyer Checklist After November 1

    Keep available:

    • Watch
    • Passport
    • Receipt
    • Warranty card
    • Product box where practical
    • Refund registration details
    • Payment card

    Do not:

    • Mail the watch home separately without checking eligibility.
    • Sell or give it away before departure.
    • place it where customs cannot inspect it.
    • assume the refund is immediate at the shop.

    Allow additional airport time if carrying several expensive purchases.


    Bringing an Expensive Watch Back to Malaysia

    Keep:

    • Original receipt
    • Warranty
    • Model information
    • Payment record
    • Tax-free documentation

    Malaysia Customs states that travellers carrying dutiable or taxable goods must use the Red Lane and declare them, while the Green Lane is for travellers with nothing subject to declaration.

    For a high-value watch, do not discard the receipt or assume wearing it automatically removes declaration obligations.

    Customs rules, exemptions and assessments can change. Check the current Royal Malaysian Customs traveller guidance before departure.


    Credit-Card Conversion Costs

    A tax-free price can still become unattractive if your payment method adds:

    • Foreign transaction fee
    • Poor exchange rate
    • Dynamic currency conversion
    • Instalment charge
    • Bank markup

    Avoid Dynamic Currency Conversion

    When the terminal asks whether to pay in:

    • Japanese yen
    • Malaysian ringgit

    Japanese yen is usually the more transparent choice because your bank performs the conversion.

    Dynamic currency conversion can use an unfavourable merchant-selected exchange rate.

    Check your card’s actual fee structure before travelling.


    Exact Price Comparison Method

    For watches above RM1,000:

    1. Record the exact reference number.
    2. Photograph the Japanese price.
    3. Confirm whether the price includes tax.
    4. Calculate the tax-free or refund amount.
    5. Add card conversion costs.
    6. Compare the Malaysian authorised-dealer price.
    7. Compare warranty coverage.
    8. Include any free strap, servicing or gifts.
    9. Check whether the Japanese model differs.
    10. Decide based on total ownership cost.

    Example

    Japan tax-inclusive price:

    ¥110,000 = RM3,300

    Potential tax removal:

    ¥10,000 = RM300

    Approximate tax-free value:

    ¥100,000 = RM3,000

    Add a hypothetical 1% card cost:

    RM30

    Effective cost:

    Approximately RM3,030

    If a Malaysian authorised dealer offers the same model for RM3,100 with local warranty and a free strap, Japan may not provide a meaningful advantage.


    Watch Authenticity Checklist

    Before paying:

    • Match the case reference with the box.
    • Check the serial number.
    • Verify dial printing.
    • Inspect hand alignment.
    • Test crown and pushers.
    • Check bracelet links.
    • Confirm warranty card.
    • Confirm store details.
    • Compare the model on the official brand website.
    • Avoid sellers refusing to issue a receipt.

    For luxury watches, buy from:

    • Brand boutique
    • Authorised dealer
    • Established pre-owned specialist

    A low price is not sufficient evidence of authenticity.


    Products That May Not Be Worth Buying

    Consider skipping:

    • Common Casio models sold at the same price in Malaysia
    • Radio-only watches bought mainly for automatic synchronisation
    • Mechanical watches you do not want to service
    • Large watches that overhang your wrist
    • Old solar watches stored without sufficient charge
    • Grey-market luxury watches without manufacturer warranty
    • Limited editions bought only because of scarcity
    • Leather straps unsuitable for Malaysian humidity
    • Watches that depend heavily on an unavailable application
    • Expensive watches purchased without checking Malaysia prices

    Common Mistakes Malaysians Make

    Assuming Every Watch Is Cheaper in Japan

    Some authorised Malaysian dealers offer substantial discounts.

    Comparing Only the Collection Name

    A Seiko Prospex may cost RM2,000 or RM15,000 depending on the exact reference.

    Ignoring Warranty Type

    A Japan-only warranty may create problems after returning home.

    Buying Radio-Controlled Watches Without Checking Coverage

    Malaysia is outside the standard Multi Band 6 transmitter regions.

    Choosing a Watch That Is Too Large

    Try the watch while looking in a full-length mirror, not only in a close-up wrist photograph.

    Forgetting Bracelet Links

    Always collect every removed link, pin and collar.

    Expecting Mechanical Watches to Match Quartz Accuracy

    Mechanical watches naturally gain or lose more time.

    Buying Because of a “Limited” Label

    A large production run may not remain collectible.

    Ignoring Servicing Costs

    A service can cost hundreds or thousands of ringgit depending on the movement.

    Discarding Receipts

    Receipts may be needed for:

    • Warranty
    • Tax refund
    • Customs
    • Insurance
    • Future resale

    Frequently Asked Questions

    What is the best Japanese watch brand?

    It depends on your needs:

    • Casio: affordable and durable
    • G-Shock: tough outdoor use
    • Citizen: solar and titanium
    • Orient: affordable mechanical watches
    • Seiko: widest range of styles and movements
    • Grand Seiko: luxury finishing and advanced movements

    Are watches cheaper in Japan?

    Sometimes.

    The strongest value usually comes from:

    • Japanese domestic models
    • Japan-only colours
    • Tax-free shopping
    • Store promotions
    • Wider selection
    • Models with high Malaysian reseller markups

    Always compare the exact reference.


    Is Seiko cheaper in Japan?

    Some models are cheaper, particularly JDM references and discounted mainstream watches.

    Premium watches may have similar official prices in Japan and Malaysia.


    Is Grand Seiko cheaper in Japan?

    The saving may be smaller than expected.

    Official Japanese and Malaysian pricing can be close after conversion.

    Tax-free treatment may create a saving, but compare authorised-dealer discounts and warranty convenience.


    Is G-Shock cheaper in Japan?

    Certain models, colours and limited editions may be cheaper.

    Globally common basic models may be similarly priced during Malaysian promotions.


    Is Citizen Attesa suitable for Malaysia?

    Yes.

    Its titanium and solar features are useful in Malaysia.

    However, terrestrial radio synchronisation may not work locally. GPS or Bluetooth models are more convenient when automatic synchronisation matters.


    Is an Orient Bambino worth buying?

    Yes, when you want an affordable mechanical dress watch.

    Check the size, water resistance and Malaysian online price before buying.


    Should I buy quartz or automatic?

    Choose quartz or solar when you want:

    • Better accuracy
    • Lower maintenance
    • Immediate readiness

    Choose automatic when you value:

    • Mechanical engineering
    • Traditional watchmaking
    • Enthusiast appeal

    Is solar better than automatic?

    Neither is universally better.

    Solar is more practical.

    Automatic is more mechanically interesting.


    Can I swim with a 50 m watch?

    Do not decide based only on the number.

    Follow the manufacturer’s stated usage guidance.

    For regular swimming, a screw-down crown and at least 100 m water resistance are safer starting points.


    Can I wear a tax-free watch in Japan?

    The safest approach is to keep the watch, receipt and documentation available for customs inspection, particularly after the refund system starts on November 1, 2026.

    Ask the retailer about the exact rules applying on your purchase date.


    Can I claim an international warranty in Malaysia?

    Only when the watch and warranty are eligible.

    Ask the Japanese authorised retailer to confirm Malaysia coverage in writing or through the official warranty system.


    How much should I budget?

    Shopping LevelJPYApprox. RM
    Affordable quartz¥5,000–20,000RM150–600
    Entry Japanese mechanical¥20,000–60,000RM600–1,800
    Presage, Prospex or premium solar¥60,000–150,000RM1,800–4,500
    Attesa, Series 8 or Oceanus¥100,000–250,000RM3,000–7,500
    Astron or premium Prospex¥200,000–450,000RM6,000–13,500
    Grand Seiko quartz¥360,000–600,000RM10,800–18,000
    Grand Seiko mechanical or Spring Drive¥600,000–1,500,000RM18,000–45,000
    High-end limited watch¥1,500,000+RM45,000+

    Final Verdict

    Japan is one of the best places for Malaysians to shop for watches because it offers a large selection across every price level.

    The strongest choices are:

    • Casio Collection for inexpensive everyday watches
    • G-Shock for durability
    • Edifice for affordable metal sports watches
    • Oceanus for premium Casio technology
    • Seiko Presage for mechanical dress watches
    • Seiko Prospex for sports and dive watches
    • Seiko Astron for GPS solar travel use
    • Citizen Eco-Drive for low-maintenance ownership
    • Citizen Attesa for lightweight titanium and solar technology
    • Citizen Series 8 for modern mechanical watches
    • Orient for affordable automatic watches
    • Orient Star for higher-grade Japanese mechanical watches
    • Grand Seiko for premium quartz, mechanical and Spring Drive movements

    For most Malaysian travellers, the most sensible budget is between RM600 and RM4,000.

    That range provides access to:

    • Citizen Eco-Drive
    • G-Shock
    • Orient mechanical watches
    • Seiko Selection
    • Seiko Presage
    • Entry Seiko Prospex
    • Orient Star

    Spend more only after confirming:

    • Exact reference number
    • Wrist fit
    • Movement type
    • International warranty
    • Malaysian price
    • Tax-free procedure
    • Future service cost
    • Radio, GPS or Bluetooth compatibility
    • Customs requirements

    The best Japanese watch is not necessarily the most complicated model, the rarest limited edition or the watch with the largest discount.

    It is the watch that fits your wrist, matches your maintenance preference and remains practical to own in Malaysia for many years.

  • Best Japanese Bags and Luggage to Buy in Japan in 2026: A Malaysian Traveller’s Guide

    Japan is a good place to shop for backpacks, crossbody bags, work bags and suitcases, especially when you want practical design rather than obvious luxury branding.

    Japanese bags often focus on:

    • Efficient internal organisation
    • Lightweight materials
    • Compact dimensions
    • Weather-resistant fabrics
    • Durable zips and hardware
    • Comfortable daily carrying
    • Simple colours
    • Repairable construction
    • Suitability for public transport

    For Malaysian travellers, a bag is worth buying in Japan when it offers something meaningfully better than what is available at home, such as a Japan-only model, Japanese manufacturing, better organisation, a lower price or a design that suits your daily routine.

    This guide covers Japanese bag brands, suitcase options, expected prices, airline restrictions, tax-free shopping and what to check before bringing a new bag home.

    Exchange Rate Used

    ¥100 = RM3.00

    Therefore:

    • ¥1,000 ≈ RM30
    • ¥5,000 ≈ RM150
    • ¥10,000 ≈ RM300
    • ¥20,000 ≈ RM600
    • ¥30,000 ≈ RM900
    • ¥50,000 ≈ RM1,500
    • ¥70,000 ≈ RM2,100

    Prices are approximate and may differ by model, material, branch, promotion and tax-free eligibility.


    Quick Answer

    The best Japanese bags and luggage to consider include:

    Brand or StoreBest ForTypical Budget
    Porter by Yoshida & Co.Premium Japanese everyday and work bagsRM700–3,000+
    ACE and ProtecaJapanese luggage and business bagsRM600–2,500+
    MUJIMinimalist suitcases and travel organisersRM60–900
    MontbellLightweight daypacks and outdoor bagsRM180–600
    Master-PieceUrban backpacks and work bagsRM450–1,800+
    AnelloAffordable casual backpacksRM100–350
    UniqloInexpensive crossbody and shoulder bagsRM45–120
    WorkmanBudget functional bagsRM45–180
    Don QuijoteEmergency suitcases and budget travel bagsRM100–600
    Hands and LoftTravel organisers and lifestyle bagsRM30–900

    For most Malaysian travellers:

    • Small crossbody bag: RM50–200
    • Good everyday backpack: RM200–600
    • Premium Japanese bag: RM700–1,800
    • Carry-on suitcase: RM300–1,200
    • Premium Japanese-made suitcase: RM1,500–2,500+
    • Emergency extra suitcase: RM150–500

    Best Bags by Traveller Type

    Best for Everyday Use in Malaysia

    Look for:

    • Lightweight nylon
    • Water-resistant fabric
    • Breathable back panels
    • Secure internal pockets
    • Bottle pockets
    • Easy-clean lining
    • Comfortable shoulder straps

    Good options include:

    • Montbell daypacks
    • Uniqlo shoulder bags
    • Anello backpacks
    • Compact Porter shoulder bags
    • ACE work backpacks

    Best for Office Workers

    Prioritise:

    • Padded laptop compartment
    • Structured base
    • Luggage handle sleeve
    • Internal cable organisation
    • Separate document area
    • Neutral colours
    • Water-resistant exterior

    Good starting points include:

    • Porter
    • ACE
    • Proteca business bags
    • Master-Piece
    • Montbell travel daypacks

    Best for Parents

    Look for:

    • Wide top opening
    • Multiple bottle pockets
    • Easy-wipe interior
    • Lightweight material
    • Strong handles
    • Backpack and tote conversion
    • External tissue pocket

    Anello-style wide-opening backpacks can be practical, but compare Japanese prices with the official Malaysian range because Anello is already sold in Malaysia.

    Best for Heavy Shopping

    Choose:

    • Foldable duffel bag
    • Expandable suitcase
    • Lightweight checked suitcase
    • Packable tote
    • Bag with luggage sleeve

    Do not buy a heavy suitcase simply because it feels strong. The suitcase’s own weight reduces how much shopping you can pack within the airline allowance.

    Best for Japanese Craftsmanship

    Consider:

    • Porter
    • Japanese-made ACE luggage
    • Proteca
    • Selected Master-Piece bags
    • Regional leather bags
    • Kurashiki canvas products
    • Toyooka-made bags

    Check the country-of-origin label carefully. A Japanese brand is not automatically manufactured in Japan.


    1. Porter by Yoshida & Co.

    Porter is one of Japan’s best-known bag labels.

    The brand is operated by Yoshida & Co. and is associated with:

    • Nylon shoulder bags
    • Military-inspired bags
    • Work bags
    • Messenger bags
    • Briefcases
    • Backpacks
    • Wallets
    • Travel accessories

    Current official Porter shoulder-bag listings include smaller models around ¥24,200–35,200, approximately RM726–1,056, while larger leather, messenger and specialist models can cost ¥50,000–110,000 or more, approximately RM1,500–3,300+.

    Popular Porter Series

    Common ranges include:

    • Tanker
    • Force
    • Heat
    • Smoky
    • Lift
    • Free Style
    • Time
    • Things
    • Monochrome
    • Potr collections

    Product availability changes frequently, and popular colours may sell out.

    Expected Prices

    Product TypeJPYApprox. RM
    Small pouch or sacoche¥20,000–30,000RM600–900
    Shoulder bag¥25,000–50,000RM750–1,500
    Backpack¥35,000–80,000RM1,050–2,400
    Work or messenger bag¥40,000–110,000RM1,200–3,300
    Leather bag¥35,000–120,000+RM1,050–3,600+

    Best Porter Bags for Malaysians

    Small Shoulder Bag

    Useful for:

    • Passport
    • Phone
    • Wallet
    • Power bank
    • Small umbrella
    • Travel documents

    A small Porter bag is easier to justify than a large premium briefcase because it can be used during the Japan trip and regularly after returning home.

    Work Backpack

    Suitable when it includes:

    • Laptop compartment
    • Bottle holder
    • Luggage sleeve
    • Document organisation
    • Water-resistant construction

    Force Series

    The Force range uses a military-inspired appearance and is available in several shoulder-bag sizes. Official listings currently show Force shoulder products from approximately ¥24,200 to ¥39,600, equivalent to around RM726–1,188.

    Is Porter Cheaper in Japan?

    It may be cheaper because you are buying in the domestic market, particularly when:

    • The model is difficult to find in Malaysia.
    • The store offers tax-free shopping.
    • You want a Japan-only colour.
    • Malaysian resellers add a significant markup.

    However, Porter has become expensive even in Japan.

    Do not buy it only because it is Japanese. Check whether the material, organisation and size justify the price for your usage.

    Porter Buying Checklist

    Check:

    1. Is the bag made in Japan?
    2. Does your laptop fit?
    3. Is the opening large enough?
    4. Are the straps comfortable when loaded?
    5. Is the lining easy to clean?
    6. Is the bag too heavy when empty?
    7. Does it have a luggage sleeve?
    8. Can the fabric handle Malaysian rain?
    9. Is the same model available in Malaysia?
    10. Are replacement parts or repairs accessible?

    2. ACE Luggage

    ACE is a major Japanese luggage and bag company.

    It sells:

    • Hard suitcases
    • Soft suitcases
    • Business backpacks
    • Briefcases
    • Crossbody bags
    • Travel accessories
    • Japanese-made luggage

    ACE’s official Japanese luggage store currently carries Made-in-Japan suitcases and bags, including work backpacks and cabin, medium and large luggage.

    Expected Prices

    Product TypeJPYApprox. RM
    Small travel bag¥8,000–20,000RM240–600
    Business backpack¥15,000–40,000RM450–1,200
    Standard suitcase¥20,000–50,000RM600–1,500
    Japanese-made suitcase¥50,000–75,000RM1,500–2,250
    Premium luggage¥60,000–100,000+RM1,800–3,000+

    ACE’s Floe Work & Daily Backpack L, for example, is listed at ¥33,000, approximately RM990. It is a Made-in-Japan expandable backpack with a stated capacity of 19 to 23 litres and space for a 15.6-inch laptop.

    Best For

    • Business travellers
    • Frequent flyers
    • Buyers who want repair support
    • Travellers seeking Japanese-made luggage
    • People who need structured work bags

    3. Proteca

    Proteca is ACE’s premium luggage brand.

    It is designed around:

    • Japanese manufacturing
    • Quiet or smooth casters
    • Caster-stop functions on selected models
    • Lightweight shells
    • Organised interiors
    • Domestic repair and support

    Proteca generally costs more than an emergency suitcase from Don Quijote or a basic MUJI case.

    Estimated Price

    ¥50,000–100,000+

    Approximately RM1,500–3,000+.

    Is Proteca Worth Buying?

    It may be worthwhile if:

    • You fly frequently.
    • You value Japanese manufacturing.
    • Smooth casters are important.
    • You want a premium suitcase with repair support.
    • You plan to keep the case for many years.

    It is less suitable when:

    • You only need a bag for one return flight.
    • Your airline allowance is limited.
    • You frequently damage or lose luggage.
    • You prefer a very lightweight soft case.
    • You can buy a comparable model cheaper in Malaysia.

    4. Japanese-Made ACE Suitcases

    ACE also sells Japanese-made luggage outside the Proteca range.

    Current official examples include cabin cases priced around ¥59,400–64,900, approximately RM1,782–1,947, while larger cases may cost approximately ¥70,400–71,500, equivalent to around RM2,112–2,145.

    Some examples include:

    • Hokkaido-inspired colour collections
    • Recycled-shell suitcases
    • Soft luggage
    • Frame cases
    • Shinkansen collaborations

    Practical Advice

    The designs may be attractive, but compare:

    • Empty weight
    • Capacity
    • Wheel system
    • Warranty
    • Malaysia suitcase prices
    • Airline size limit
    • Availability of replacement wheels

    Do not spend RM2,000 on a suitcase solely because it is a Hokkaido-exclusive colour.

    The mechanical parts and long-term usability matter more than the pattern.


    5. MUJI Suitcases

    MUJI is a practical option for travellers who want minimalist luggage without buying a premium Japanese luxury case.

    MUJI Japan’s 2026 travel range includes hard carry cases. Its official store lists a 20-litre 2026-specification hard case from approximately ¥19,900, equivalent to around RM597, with other sizes and specifications costing more.

    Estimated Price

    Product TypeJPYApprox. RM
    Small travel bag¥2,000–8,000RM60–240
    Packable duffel¥3,000–10,000RM90–300
    Small hard carry case¥19,900–25,000RM597–750
    Medium suitcase¥25,000–35,000RM750–1,050
    Large suitcase¥30,000–45,000RM900–1,350

    Best Features

    Depending on the model, MUJI suitcases may offer:

    • Minimal branding
    • Neutral colours
    • Caster lock
    • Simple interiors
    • Replaceable or repairable components
    • Multiple sizes
    • Matching travel organisers

    Is MUJI Luggage Cheaper in Japan?

    Possibly, but compare with MUJI Malaysia.

    A standard grey or black MUJI suitcase may already be available locally.

    Japan is more worthwhile when:

    • The model is newly released.
    • The size is unavailable in Malaysia.
    • The Japan price is significantly lower.
    • You need the suitcase immediately.
    • A tax-free branch is available.

    MUJI provides tax-free services at participating Japanese branches, but availability depends on the store.


    6. Montbell Backpacks

    Montbell is one of Japan’s strongest practical outdoor brands.

    It offers:

    • Lightweight daypacks
    • Hiking backpacks
    • Travel backpacks
    • Packable bags
    • Shoulder bags
    • Waterproof accessories
    • Trekking packs

    Current Montbell Japan listings show basic daypacks from around ¥6,000–6,900, approximately RM180–207. Its Travel Daypack 20 is listed at ¥11,900, approximately RM357.

    Expected Prices

    Product TypeJPYApprox. RM
    Packable bag¥2,000–5,000RM60–150
    Basic daypack¥6,000–10,000RM180–300
    Travel daypack¥10,000–15,000RM300–450
    Hiking backpack¥12,000–30,000RM360–900
    Large trekking pack¥20,000–45,000RM600–1,350

    Best For Malaysians

    Montbell bags are useful for:

    • Day trips
    • Hiking
    • Family travel
    • Carrying water and umbrellas
    • Future cold-weather trips
    • Lightweight cabin packing

    What to Check

    A hiking backpack may have:

    • Narrower compartments
    • Less laptop padding
    • A curved back frame
    • External straps
    • A top-loading opening

    For office use, choose a travel or urban model rather than a technical hiking pack.


    7. Master-Piece

    Master-Piece is a Japanese urban-bag brand commonly associated with:

    • Backpacks
    • Work bags
    • Sling bags
    • Messenger bags
    • Technical fabrics
    • Leather detailing
    • Japanese manufacturing on selected models

    Estimated Price

    Product TypeJPYApprox. RM
    Small shoulder bag¥10,000–25,000RM300–750
    Backpack¥20,000–50,000RM600–1,500
    Work bag¥25,000–60,000RM750–1,800
    Limited edition¥30,000–70,000+RM900–2,100+

    Best For

    • Urban commuting
    • Laptop carrying
    • Buyers who want something less common than Porter
    • Technical-material enthusiasts
    • Smart-casual office use

    Buying Advice

    Check the specific model’s country of manufacture.

    Master-Piece uses different materials and construction depending on the collection.

    Do not assume every bag is fully made in Japan merely because the brand is Japanese.


    8. Anello

    Anello is known for backpacks with a wide framed opening.

    Common designs include:

    • Kuchigane backpacks
    • Mini backpacks
    • Tote-backpack hybrids
    • Crossbody bags
    • Boston bags
    • Water-repellent models

    Estimated Price in Japan

    ¥3,000–10,000

    Approximately RM90–300.

    Best For

    • Parents
    • Students
    • Casual travel
    • Wide-opening access
    • Affordable everyday carrying

    Is It Worth Buying in Japan?

    Anello has an official Malaysian retail presence, including an official store in Sunway Pyramid, so compare the exact model before buying in Japan.

    Buying in Japan is more worthwhile when:

    • The colour is Japan-exclusive.
    • The model is unavailable in Malaysia.
    • The Japanese price is clearly lower.
    • You find a seasonal collaboration.

    9. Uniqlo Bags

    Uniqlo is one of the easiest places to buy an affordable bag during a Japan trip.

    The Round Mini Shoulder Bag is currently listed around ¥1,990, approximately RM59.70. Uniqlo Japan also promotes local UTme! tote bags at selected large stores.

    Estimated Price

    Product TypeJPYApprox. RM
    Mini shoulder bag¥1,500–2,990RM45–89.70
    Tote bag¥1,500–3,000RM45–90
    Backpack¥2,990–5,990RM89.70–179.70
    Collaboration bag¥1,990–6,990RM59.70–209.70

    Best For

    • Low-cost travel bags
    • Lightweight everyday use
    • Children or teenagers
    • Extra shopping storage
    • Simple crossbody bags

    Buying Advice

    Check Uniqlo Malaysia before buying a standard black model.

    Japan is more worthwhile for:

    • Exclusive colours
    • UTme! local designs
    • Collaborations
    • Clearance stock

    10. Workman Bags

    Workman may sell affordable functional bags such as:

    • Backpacks
    • Tool bags
    • Waterproof bags
    • Small shoulder bags
    • Outdoor bags
    • Motorcycle bags

    Estimated Price

    ¥1,500–6,000

    Approximately RM45–180.

    Best For

    • Gardening
    • Outdoor work
    • Rainy conditions
    • Motorcycling
    • Backup travel use
    • Budget shoppers

    Workman bags should be judged as functional value products rather than premium fashion accessories.

    Check:

    • Stitching
    • Zip smoothness
    • Waterproof construction
    • Strap comfort
    • Maximum practical load

    11. Foldable Shopping Bags

    A foldable bag is one of the most useful low-cost purchases in Japan.

    It can be used for:

    • Supermarket shopping
    • Don Quijote purchases
    • Laundry
    • Last-day packing
    • Carrying jackets
    • Separating gifts

    Estimated Price

    ¥300–3,000

    Approximately RM9–90.

    Best Places to Buy

    • Daiso
    • Seria
    • Can Do
    • Uniqlo
    • MUJI
    • Loft
    • Hands
    • Supermarkets

    What to Check

    Choose:

    • Reinforced handles
    • Secure seams
    • Water-resistant fabric
    • Compact folded size
    • Zipped top if used for travel
    • Stated weight capacity

    A thin ¥110 bag may be suitable for snacks but not for several kilograms of books or ceramics.


    12. Packable Duffel Bags

    A foldable duffel is useful for travellers who expect to shop heavily.

    Estimated Price

    ¥1,500–10,000

    Approximately RM45–300.

    Best Features

    Look for:

    • Zip closure
    • Luggage-handle sleeve
    • Reinforced base
    • Internal pocket
    • Lockable zip
    • Cabin-compatible dimensions
    • Strong shoulder strap

    Important Warning

    A soft duffel provides little protection for:

    • Cosmetics in glass bottles
    • Ceramics
    • Electronics
    • Biscuits
    • Collectibles

    Use it mainly for clothing and soft purchases.


    13. Emergency Suitcases from Don Quijote

    Don Quijote is a common place to buy an additional suitcase near the end of a trip.

    Possible price levels include:

    • Budget soft case
    • Basic hard case
    • Expandable case
    • Character suitcase
    • Branded luggage
    • Premium Japanese case

    Estimated Price

    TypeJPYApprox. RM
    Very basic case¥4,000–8,000RM120–240
    Standard medium case¥8,000–18,000RM240–540
    Better branded case¥18,000–35,000RM540–1,050
    Premium luggage¥35,000+RM1,050+

    What to Check Before Buying

    1. Open and close every zip.
    2. Extend the handle several times.
    3. Roll all four wheels.
    4. Check the shell for cracks.
    5. Confirm the case includes keys or lock instructions.
    6. Measure the total dimensions.
    7. Check the empty weight.
    8. Verify the return policy.
    9. Confirm whether it is tax-free.
    10. Check your airline allowance before paying.

    Hard Case vs Soft Case

    FeatureHard CaseSoft Case
    Impact protectionBetter for many itemsLower
    External pocketsRareCommon
    ExpandabilityModel dependentOften available
    Water resistanceGenerally betterDepends on fabric
    WeightCan be heavierOften lighter
    FlexibilityLowBetter
    Scratch visibilityHigherLower
    Packing into tight spacesHarderEasier

    Choose a Hard Case When

    • Carrying fragile products
    • Checking the bag
    • Travelling in rain
    • Carrying structured gifts
    • You prefer a divided interior

    Choose a Soft Case When

    • Weight is the main concern.
    • You need external pockets.
    • You carry mostly clothing.
    • You want some flexibility.
    • The case must fit into irregular storage spaces.

    Polycarbonate vs ABS Suitcases

    Polycarbonate

    Advantages:

    • More flexible
    • Better impact resistance
    • Usually more durable

    Disadvantages:

    • Often more expensive
    • Can still scratch
    • Quality varies by thickness and construction

    ABS

    Advantages:

    • Lower price
    • Rigid feel
    • Suitable for occasional travel

    Disadvantages:

    • May crack more easily
    • Often heavier for the strength provided
    • Less flexible under impact

    Mixed Materials

    Some cases combine ABS and polycarbonate.

    Do not judge quality solely from the material label. Wheel quality, frame design, handle strength and shell thickness also matter.


    Two Wheels vs Four Spinner Wheels

    Two Wheels

    Advantages:

    • Often stronger on rough surfaces
    • Wheels may be better protected
    • Good for pulling over uneven paths

    Disadvantages:

    • Must be tilted
    • Less convenient inside stations
    • More weight on the arm

    Four Spinner Wheels

    Advantages:

    • Easy to move through stations
    • Can roll beside you
    • More convenient in queues
    • Better for smooth airport floors

    Disadvantages:

    • Wheels project outward
    • Cheap wheels may break
    • Less stable on slopes
    • Can roll away without a brake

    For most Japan trips, four spinner wheels are more convenient because of airports, train stations and paved city streets.


    Caster Stopper

    A wheel-lock function is useful in Japan because luggage can move inside:

    • Trains
    • Airport buses
    • Sloped station platforms
    • Hotel lobbies
    • Elevators

    A caster stopper is useful, but it should not be the only reason to buy an expensive suitcase.

    Test that the lock:

    • Engages firmly
    • Releases easily
    • Does not feel fragile
    • Controls the correct wheels

    TSA Locks

    Many suitcases sold in Japan include a TSA-compatible lock.

    A TSA lock is mainly relevant when travelling to countries where authorised security staff may inspect luggage using an approved master key.

    It does not make the suitcase theft-proof.

    Do not store valuables such as:

    • Passport
    • Cash
    • Laptop
    • Jewellery
    • Medication
    • Power banks

    inside checked luggage.


    Smart Luggage and Batteries

    Be careful with suitcases containing:

    • Power banks
    • Built-in batteries
    • Tracking devices
    • Electronic locks
    • Motorised ride-on functions

    Airlines may require lithium batteries to be removable.

    Malaysia Airlines provides specific smart-baggage guidance, while AirAsia limits cabin baggage by both weight and battery-related rules.

    Avoid buying unfamiliar smart luggage without confirming:

    • Battery capacity
    • Whether the battery is removable
    • Airline acceptance
    • Charging standards
    • Warranty
    • Replacement-battery availability

    Cabin Bag Rules for Malaysians

    Do not assume a suitcase labelled “cabin size” is accepted by every airline.

    Airline rules differ by:

    • Aircraft
    • Fare type
    • Route
    • Cabin class
    • Bag dimensions
    • Total weight
    • Number of pieces

    AirAsia currently allows two cabin items with a combined weight of no more than 7 kg under its standard cabin-baggage policy. Its support page also states that from April 13, 2026, Xtra Carry-On must be purchased online or through the app before departure rather than at airport counters.

    Malaysia Airlines applies its own cabin rules and advises passengers to confirm the permitted size and weight for their travel class.

    Practical Rule

    Before buying a cabin suitcase:

    1. Open your airline booking.
    2. Check the exact baggage entitlement.
    3. Record maximum dimensions.
    4. Record combined weight.
    5. Measure the suitcase including wheels and handle.
    6. Weigh the empty suitcase.
    7. Leave enough weight for actual contents.

    A 3.5 kg cabin suitcase leaves only 3.5 kg for belongings when your total allowance is 7 kg.


    Checked-Luggage Dimensions

    Malaysia Airlines states that checked baggage should generally remain within a total linear dimension of 158 cm, calculated by adding length, height and width. It also states that the maximum weight for a single checked item is 32 kg.

    This does not mean every ticket includes 32 kg.

    Your actual free allowance depends on:

    • Route
    • Fare
    • Cabin
    • Loyalty status
    • Ticket conditions

    A suitcase can be physically capable of holding 35 kg while your ticket allows only 20 kg.


    How to Measure a Suitcase

    Use:

    Height + Width + Depth

    Include:

    • Wheels
    • Handles
    • External pockets
    • Protective corners

    Example:

    • Height: 75 cm
    • Width: 50 cm
    • Depth: 30 cm

    Total:

    75 + 50 + 30 = 155 cm

    This is below a 158 cm total linear limit.

    An expandable case may exceed the limit when fully opened.


    Best Suitcase Size for Different Trips

    Trip TypeSuggested Capacity
    One or two nights20–35 L
    Three to five nights40–65 L
    Five to eight nights65–85 L
    Long trip or heavy shopping85–110 L

    These are only practical estimates.

    The right size also depends on:

    • Season
    • Laundry access
    • Shopping plans
    • Airline allowance
    • Whether you share luggage
    • Whether you pack winter clothing

    Best Bag Capacity for Daily Use

    2–5 Litres

    Suitable for:

    • Phone
    • Wallet
    • Passport
    • Small power bank
    • Tissues

    6–12 Litres

    Suitable for:

    • Small umbrella
    • Water bottle
    • Camera
    • Light jacket
    • Travel documents

    15–22 Litres

    Suitable for:

    • Daily sightseeing
    • Tablet
    • Small laptop
    • Family items
    • Shopping

    23–30 Litres

    Suitable for:

    • One-night trip
    • Large laptop
    • Camera gear
    • Winter layers
    • Heavy daily load

    A larger bag encourages overpacking.

    For ordinary city sightseeing, approximately 10–20 litres is usually sufficient.


    Laptop Bag Sizing

    Do not rely only on descriptions such as “15-inch compatible.”

    Laptop fit depends on:

    • Width
    • Height
    • Thickness
    • Protective sleeve
    • Charger size
    • Pocket opening

    Measure your laptop in centimetres before travelling.

    Check whether the compartment includes:

    • Bottom padding
    • Suspended base
    • Side protection
    • Zip protection
    • Water resistance

    A padded compartment that touches the bottom of the bag may still transmit impact when the bag is placed down.


    Water-Resistant vs Waterproof

    Water-Resistant

    Suitable for:

    • Light rain
    • Short exposure
    • Minor splashes

    It does not guarantee protection during a Malaysian thunderstorm.

    Waterproof

    A genuinely waterproof bag should use features such as:

    • Sealed seams
    • Waterproof fabric
    • Roll-top closure
    • Protected zips
    • Tested waterproof rating

    Many urban bags use water-repellent fabric but are not fully waterproof.

    For electronics, use an internal waterproof pouch even when the bag is marketed as water-resistant.


    Japanese Bag Shopping Vocabulary

    JapaneseMeaning
    バッグBag
    リュックBackpack
    ショルダーバッグShoulder bag
    トートバッグTote bag
    ボストンバッグBoston or duffel bag
    キャリーケースWheeled suitcase
    スーツケースSuitcase
    機内持ち込みCabin carry-on
    容量Capacity
    重量Weight
    防水Waterproof
    撥水Water-repellent
    拡張Expandable
    日本製Made in Japan
    牛革Cow leather
    ナイロンNylon
    ポリエステルPolyester
    在庫Stock
    修理Repair
    保証Warranty
    免税Tax-free

    Useful phrases include:

    このバッグは何リットルですか?
    How many litres is this bag?

    パソコンは入りますか?
    Will a laptop fit?

    機内持ち込みできますか?
    Can this be carried into the cabin?

    重さは何キロですか?
    How many kilograms does it weigh?

    日本製ですか?
    Is it made in Japan?


    Tax-Free Bag Shopping in 2026

    Japan’s tourist tax-free system changes on November 1, 2026.

    Purchases Before November 1, 2026

    Eligible visitors can generally complete tax-free shopping at participating stores by:

    • Presenting their passport
    • Meeting the applicable minimum spend
    • Completing the store’s tax-free procedure
    • Following the applicable export rules

    Not every branch participates.

    Purchases From November 1, 2026

    Japan moves to a refund-based system.

    Under the revised method:

    1. You pay the tax-inclusive price.
    2. You must take the goods out of Japan within 90 days of purchase.
    3. Customs confirms at departure that you are carrying the goods.
    4. The retailer or its appointed process refunds the consumption-tax-equivalent amount after confirmation.

    Bag-Specific Advice

    Keep:

    • Receipt
    • Product tag
    • Tax-free transaction record
    • Bag or suitcase accessible
    • Original packaging when practical

    Do not send a tax-free suitcase home separately unless the applicable process explicitly permits it.


    Example RM100 Bag Budget

    RM100 is approximately ¥3,333.

    Possible purchases:

    ProductJPYApprox. RM
    Uniqlo shoulder bag¥1,990RM59.70
    Foldable shopping bag¥500RM15
    Small packing pouch¥500RM15
    Luggage tag¥300RM9
    Total¥3,290RM98.70

    Example RM300 Bag Budget

    RM300 is approximately ¥10,000.

    ProductJPYApprox. RM
    Montbell basic daypack¥6,000RM180
    Foldable duffel¥2,000RM60
    Travel organiser¥1,000RM30
    Rain cover¥800RM24
    Total¥9,800RM294

    Example RM600 Bag Budget

    RM600 is approximately ¥20,000.

    Possible purchases include:

    • MUJI carry case
    • Better Montbell travel backpack
    • Anello backpack plus organisers
    • Emergency medium suitcase
    • Discounted Japanese work bag

    Example:

    ProductJPYApprox. RM
    MUJI small hard case¥19,900RM597
    Total¥19,900RM597

    Example RM1,000 Bag Budget

    RM1,000 is approximately ¥33,333.

    ProductJPYApprox. RM
    ACE work backpack¥33,000RM990
    Total¥33,000RM990

    The cited ACE Floe Work & Daily Backpack L is one current example at this price level.


    Example RM1,500 Bag Budget

    RM1,500 is approximately ¥50,000.

    Possible purchases include:

    • Porter backpack
    • Porter shoulder bag plus wallet
    • Premium Master-Piece work bag
    • Entry-level premium Japanese suitcase

    Example allocation:

    CategoryJPYApprox. RM
    Premium Japanese shoulder bag¥35,000RM1,050
    Foldable duffel¥5,000RM150
    Packing organisers¥3,000RM90
    Luggage accessories¥2,000RM60
    Remaining budget¥5,000RM150
    Total¥50,000RM1,500

    Example RM2,000 Suitcase Budget

    RM2,000 is approximately ¥66,667.

    This can cover:

    • Japanese-made ACE cabin luggage
    • Selected premium Proteca models
    • Japanese-made recycled-shell luggage
    • A premium suitcase plus travel accessories

    Official ACE examples currently place several Japanese-made cabin cases around ¥59,400–64,900, approximately RM1,782–1,947.


    Best Bags Under RM100

    RM100 is approximately ¥3,333.

    Good options include:

    • Uniqlo shoulder bag
    • Foldable tote
    • Daiso travel pouches
    • MUJI organisers
    • Workman small bag
    • Basic Anello crossbody bag
    • Packable shopping bag

    Best Bags Under RM300

    RM300 is approximately ¥10,000.

    Good options include:

    • Montbell basic daypack
    • Anello backpack
    • Workman backpack
    • Foldable duffel
    • Budget suitcase
    • Japanese lifestyle-brand tote
    • Discounted ACE bag

    Best Bags Under RM600

    RM600 is approximately ¥20,000.

    Good options include:

    • MUJI small hard suitcase
    • Montbell travel backpack
    • Master-Piece small bag
    • Better ACE work bag
    • Better emergency suitcase
    • Entry-level Japanese-made canvas bag

    Best Bags Under RM1,000

    RM1,000 is approximately ¥33,333.

    Good options include:

    • ACE Made-in-Japan work backpack
    • Porter small shoulder bag
    • Master-Piece backpack
    • Premium Montbell pack
    • Better MUJI suitcase
    • Japanese leather crossbody bag

    What to Check Before Buying a Suitcase

    1. Empty Weight

    A heavier case reduces your usable baggage allowance.

    2. External Dimensions

    Measure wheels and handles.

    3. Capacity

    Do not judge only by external appearance.

    4. Wheel Quality

    Roll it:

    • Straight
    • Sideways
    • In a circle
    • Over small floor joints

    5. Telescopic Handle

    Test all height positions.

    6. Main Zip

    Check whether it catches at the corners.

    7. Expansion

    Confirm the expanded dimensions.

    8. Interior Straps

    Check whether they hold clothing securely.

    9. Warranty

    Find out whether warranty service is available outside Japan.

    10. Replacement Parts

    Ask whether wheels and handles can be replaced.


    What to Check Before Buying a Backpack

    Strap Comfort

    Load the bag before judging it.

    Back Length

    A bag can be too long even when the capacity is suitable.

    Laptop Protection

    Check the compartment’s bottom and side padding.

    Bottle Pocket

    Confirm it fits your actual bottle size.

    Zip Security

    For crowded trains, use:

    • Internal valuables pocket
    • Lockable zips
    • Rear passport pocket

    Empty Weight

    Some premium backpacks weigh more than 1.5 kg before loading.

    Organisation

    Too many small pockets can make items difficult to find.


    Carrying Bags on Japanese Trains

    During crowded periods:

    • Remove a large backpack from your back.
    • Hold it in front of you.
    • Use overhead racks when safe.
    • Keep suitcase wheels locked where possible.
    • Avoid blocking doors.
    • Do not leave luggage unattended.
    • Use luggage areas on reserved trains when required.

    A very large backpack may be uncomfortable during Tokyo rush hour even when it is technically cabin-compatible.


    Bringing a New Suitcase Back to Malaysia

    You have several options.

    Option 1: Use It as Your Main Checked Bag

    Transfer your shopping into the new suitcase and place your original soft bag inside when possible.

    Option 2: Check Both Bags

    This requires sufficient baggage allowance.

    Option 3: Place One Suitcase Inside Another

    This works only when:

    • The larger suitcase has enough space.
    • The total weight remains acceptable.
    • The inner case does not damage the outer case.

    Option 4: Use a Foldable Duffel for Clothing

    Place fragile purchases in the suitcase and clothing in the duffel.

    Option 5: Purchase Additional Baggage

    Buying baggage in advance is normally cheaper than discovering an excess-weight problem at the airport.


    Packing a New Suitcase Safely

    Remove Loose Accessories

    Detach straps or charms that may catch on airport equipment.

    Protect Glossy Surfaces

    Use the supplied cover or protective film when appropriate.

    Do Not Overexpand

    An overfilled case stresses:

    • Zips
    • Shell
    • Hinges
    • Handles

    Keep Valuables in Cabin Baggage

    Never check:

    • Passport
    • Cash
    • Medication
    • Power bank
    • Laptop
    • Jewellery

    Photograph the Bag

    Take clear photos before check-in.

    Add Identification

    Use:

    • Name tag
    • Phone number
    • Email address
    • Distinctive strap

    Avoid displaying your complete home address publicly.


    Products That May Not Be Worth Buying

    Consider skipping:

    • Very cheap suitcases with weak wheels
    • Heavy cases exceeding 5 kg empty
    • Standard Anello bags already available in Malaysia
    • Ordinary Uniqlo bags sold locally
    • Premium bags without useful internal organisation
    • Leather bags that are difficult to maintain in humidity
    • Cabin bags that exceed your airline dimensions
    • Smart luggage with a non-removable battery
    • White fabric luggage that stains easily
    • Bags bought only to meet the tax-free threshold

    Common Mistakes Malaysians Make

    Buying Based on Capacity Alone

    A 40-litre bag may be too large for daily commuting.

    Ignoring Empty Weight

    The suitcase itself can consume a large part of a 7 kg cabin allowance.

    Assuming “Cabin Size” Means Every Airline

    Always check your exact carrier and fare.

    Buying a Japanese Brand Without Checking Origin

    The product may be manufactured outside Japan.

    Ignoring Malaysian Availability

    MUJI, Uniqlo and Anello are already sold locally.

    Choosing Too Many Compartments

    Excessive organisation can reduce usable space.

    Not Testing the Wheels

    Cheap wheels are a common failure point.

    Buying a Suitcase on the Final Morning

    You need time to:

    • Inspect it
    • Repack
    • Weigh it
    • Return it if defective

    Discarding Receipts

    Keep receipts until you return home.

    Buying a Premium Bag That Does Not Suit Malaysian Rain

    Use internal waterproof protection for electronics.


    Frequently Asked Questions

    What is the best Japanese bag brand?

    For premium everyday bags:

    • Porter
    • Master-Piece

    For luggage and business bags:

    • ACE
    • Proteca

    For outdoor and lightweight bags:

    • Montbell

    For affordable casual bags:

    • Anello
    • Uniqlo
    • Workman

    Is Porter cheaper in Japan?

    It can be cheaper than buying through Malaysian importers, especially with tax-free shopping.

    However, Porter’s domestic prices are still relatively high.

    Compare the exact model before buying.


    Is Porter made in Japan?

    Many Porter products are associated with Japanese manufacturing, but check the label and individual product description.

    Do not rely only on the brand name.


    Is Anello cheaper in Japan?

    Possibly, but Anello is officially available in Malaysia.

    Japan is most worthwhile for exclusive colours, special editions or substantial price differences.


    Is MUJI luggage cheaper in Japan?

    Some sizes may be cheaper or launched earlier in Japan.

    Compare the exact model and capacity with MUJI Malaysia.


    Is a Japanese-made suitcase worth RM2,000?

    It may be worthwhile for frequent travellers who value:

    • Better casters
    • Repair support
    • Japanese manufacturing
    • Long-term use

    It is usually unnecessary for an occasional traveller who only needs additional shopping capacity.


    What is the best suitcase size for a seven-day Japan trip?

    Approximately 65–85 litres is practical for many travellers.

    Heavy shoppers may need a larger case or an additional foldable bag.

    Winter clothing requires more space than summer clothing.


    Should I buy a suitcase at Don Quijote?

    Yes, when you need an extra case quickly.

    Inspect the wheels, handle, shell, zips and empty weight before paying.


    Can I bring two cabin bags on AirAsia?

    AirAsia currently permits two cabin items under its standard policy, but their combined weight must not exceed 7 kg unless you have purchased an applicable additional allowance.

    Check your booking because policies and purchased add-ons can differ.


    Can a power bank remain inside smart luggage?

    Airline rules frequently require lithium batteries to be removable or carried in the cabin.

    Check the exact luggage battery specifications and airline policy before buying.


    Should I keep the suitcase box?

    Usually not.

    A suitcase box is bulky and unnecessary for normal travel.

    Keep:

    • Receipt
    • Warranty card
    • Product label
    • Lock instructions
    • Replacement-part information

    How much should I budget?

    Purchase LevelJPYApprox. RM
    Small bag¥1,500–6,000RM45–180
    Everyday backpack¥6,000–20,000RM180–600
    Premium Japanese bag¥20,000–60,000RM600–1,800
    Standard suitcase¥10,000–35,000RM300–1,050
    Japanese-made suitcase¥50,000–75,000RM1,500–2,250
    Premium luggage¥75,000+RM2,250+

    Final Verdict

    Japan is a strong place to buy bags and luggage, particularly when you want practical Japanese design, good organisation or domestic manufacturing.

    The best options include:

    • Porter for premium shoulder, work and messenger bags
    • ACE for business bags and Japanese luggage
    • Proteca for premium suitcases
    • MUJI for minimalist luggage
    • Montbell for lightweight daypacks and travel bags
    • Master-Piece for urban backpacks
    • Anello for affordable wide-opening backpacks
    • Uniqlo for inexpensive crossbody bags
    • Workman for budget functional bags
    • Don Quijote for emergency suitcases

    For most Malaysian travellers:

    • Spend RM50–200 on a small travel bag.
    • Spend RM200–600 on a good everyday backpack.
    • Spend RM700–1,800 on a premium Japanese bag.
    • Spend RM300–1,000 on a practical suitcase.
    • Spend RM1,500–2,500 only when you specifically want Japanese-made premium luggage.

    Before buying, check:

    • Empty weight
    • Capacity
    • Dimensions
    • Laptop compatibility
    • Wheel quality
    • Water resistance
    • Country of manufacture
    • Warranty
    • Malaysia pricing
    • Airline allowance

    The best Japanese bag is not the one with the most pockets or the highest price.

    It is the bag that remains comfortable when fully loaded, fits your actual travel and work needs, and can be carried home without creating a baggage problem.