xiaohongshu
xiaohongshu
laravel
Laravel and PHP development patterns and best practices
Full skill instructions
Comprehensive patterns and best practices for Laravel and PHP development.
app/
├── Http/
│ ├── Controllers/ # Controllers
│ ├── Middleware/ # Middleware
│ ├── Requests/ # Form Requests (validation)
│ └── Resources/ # API Resources
├── Models/ # Eloquent models
├── Services/ # Business logic
├── Exceptions/ # Custom exceptions
└── Helpers/ # Helper functions
API Routes
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('users', UserController::class);
Route::prefix('admin')->group(function () {
Route::apiResource('posts', PostController::class);
});
});
Web Routes
// routes/web.php
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::resource('posts', PostController::class);
Best Practice Controller
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
class PostController extends Controller
{
public function index()
{
$posts = Post::with(['author', 'tags'])
->latest()
->paginate(20);
return PostResource::collection($posts);
}
public function store(StorePostRequest $request): JsonResponse
{
$post = $this->postService->create($request->validated());
return response()->json([
'data' => new PostResource($post),
], 201);
}
public function update(StorePostRequest $request, Post $post): JsonResponse
{
$post = $this->postService->update($post, $request->validated());
return response()->json([
'data' => new PostResource($post),
]);
}
public function destroy(Post $post): JsonResponse
{
$this->authorize('delete', $post);
$post->delete();
return response()->json(null, 204);
}
}
Service Class
<?php
namespace App\Services;
use App\Models\Post;
use Illuminate\Database\Eloquent\Model;
class PostService
{
public function create(array $data): Post
{
$post = Post::create($data);
// Handle relationships
if (isset($data['tags'])) {
$post->tags()->sync($data['tags']);
}
return $post->load(['author', 'tags']);
}
public function update(Post $post, array $data): Post
{
$post->update($data);
if (isset($data['tags'])) {
$post->tags()->sync($data['tags']);
}
return $post->load(['author', 'tags']);
}
}
Eloquent Model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'title',
'content',
'author_id',
'published_at',
];
protected $casts = [
'published_at' => 'datetime',
];
protected $hidden = [
'deleted_at',
];
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)
->withTimestamps();
}
public function scopePublished($query)
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopeWithTag($query, string $tag)
{
return $query->whereHas('tags', function ($q) use ($tag) {
$q->where('name', $tag);
});
}
}
Form Request
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
'tags' => ['array'],
'tags.*' => ['exists:tags,id'],
'published_at' => ['nullable', 'date'],
];
}
public function messages(): array
{
return [
'title.required' => 'A title is required',
'content.required' => 'Content cannot be empty',
];
}
}
Resource
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'excerpt' => Str::limit($this->content, 150),
'author' => new UserResource($this->whenLoaded('author')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
Migration
<?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::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained('users')->cascadeOnDelete();
$table->string('title');
$table->text('content');
$table->timestamp('published_at')->nullable();
$table->softDeletes();
$table->timestamps();
$table->index(['author_id', 'published_at']);
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
Job Class
<?php
namespace App\Jobs;
use App\Models\Post;
use App\Services\NotificationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class PublishPostJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 120;
public function __construct(
public Post $post
) {}
public function handle(NotificationService $notification): void
{
$this->post->update(['published_at' => now()]);
$notification->notifyPostPublished($this->post);
}
public function failed(\Throwable $exception): void
{
\Log::error('Failed to publish post: ' . $exception->getMessage());
}
}
Event
<?php
namespace App\Events;
use App\Models\Post;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PostPublished
{
use Dispatchable, SerializesModels;
public function __construct(
public Post $post
) {}
}
Listener
<?php
namespace App\Listeners;
use App\Events\PostPublished;
use App\Services\SubscriptionService;
class NotifySubscribers
{
public function __construct(
private SubscriptionService $subscription
) {}
public function handle(PostPublished $event): void
{
$this->subscription->notifyAbout($event->post);
}
}
Custom Middleware
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserHasRole
{
public function handle(Request $request, Closure $next, string $role): Response
{
if (!$request->user()?->hasRole($role)) {
return response()->json(['error' => 'Forbidden'], 403);
}
return $next($request);
}
}
Service Provider
<?php
namespace App\Providers;
use App\Services\PostService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(PostService::class);
}
public function boot(): void
{
// Blade directives, macros, etc.
}
}
Feature Test
<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostManagementTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_create_post(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->postJson('/api/posts', [
'title' => 'Test Post',
'content' => 'Test content',
]);
$response->assertStatus(201)
->assertJsonPath('data.title', 'Test Post');
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'author_id' => $user->id,
]);
}
}
Repository Pattern (Optional)
<?php
namespace App\Repositories;
use App\Models\Post;
use Illuminate\Pagination\LengthAwarePaginator;
interface PostRepositoryInterface
{
public function all(): LengthAwarePaginator;
public function find(int $id): ?Post;
public function create(array $data): Post;
public function update(Post $post, array $data): Post;
public function delete(Post $post): bool;
}
class EloquentPostRepository implements PostRepositoryInterface
{
public function all(): LengthAwarePaginator
{
return Post::with(['author', 'tags'])
->latest()
->paginate(20);
}
// ... other methods
}
with())$fillable or guardedRESTful Resource Controller
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('users', UserController::class);
Route::apiResource('posts', PostController::class);
});
// With additional routes
Route::prefix('posts')->group(function () {
Route::apiResource('posts', PostController::class);
Route::get('posts/{post}/comments', [PostCommentController::class, 'index']);
Route::post('posts/{post}/comments', [PostCommentController::class, 'store']);
});
API Versioning
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('users', UserV1Controller::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('users', UserV2Controller::class);
});
Standardized Response
// app/Http/Responses/ApiResponse.php
<?php
namespace App\Http\Responses;
class ApiResponse
{
public static function success(
$data = null,
string $message = 'Success',
int $statusCode = 200
) {
return response()->json([
'success' => true,
'message' => $message,
'data' => $data,
], $statusCode);
}
public static function error(
string $message = 'Error',
int $statusCode = 400,
$errors = null
) {
return response()->json([
'success' => false,
'message' => $message,
'errors' => $errors,
], $statusCode);
}
public static function paginated(
$data,
int $statusCode = 200
) {
return response()->json([
'success' => true,
'data' => $data->items(),
'meta' => [
'current_page' => $data->currentPage(),
'last_page' => $data->lastPage(),
'per_page' => $data->perPage(),
'total' => $data->total(),
],
], $statusCode);
}
}
Rate Limiting
// routes/api.php
use Illuminate\Support\Facades\RateLimiter;
// Global API rate limit
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Apply to routes
Route::middleware(['throttle:api'])->group(function () {
Route::apiResource('posts', PostController::class);
});
// Stricter for auth endpoints
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
Route::middleware(['throttle:auth'])->group(function () {
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
});
Query Scopes
// app/Models/Post.php
class Post extends Model
{
public function scopePublished($query)
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopeWithTag($query, string $tag)
{
return $query->whereHas('tags', function ($q) use ($tag) {
$q->where('name', $tag);
});
}
public function scopeSearch($query, string $search)
{
return $query->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%");
}
}
// Usage
$posts = Post::published()->withTag('laravel')->search('tutorial')->get();
Eager Loading Strategies
// Prevent N+1 queries
$posts = Post::with(['author', 'tags', 'comments'])->paginate(20);
// Nested eager loading
$users = User::with(['posts.tags', 'posts.comments'])->get();
// Conditional eager loading
$posts = Post::when(request()->include('author'), function ($q) {
return $q->with('author');
})->get();
// Lazy eager loading
$posts = Post::all();
$posts->load(['author', 'tags']);
Database Transactions
// app/Services/OrderService.php
class OrderService
{
public function createOrder(array $data): Order
{
return DB::transaction(function () use ($data) {
// Create order
$order = Order::create([
'user_id' => $data['user_id'],
'total' => $data['total'],
]);
// Create order items
foreach ($data['items'] as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $item['price'],
]);
// Update inventory
Product::where('id', $item['product_id'])
->decrement('stock', $item['quantity']);
}
// Process payment
$this->paymentService->charge($order);
return $order;
});
}
}
Query Optimization
// Select only needed columns
$users = User::select(['id', 'name', 'email'])->get();
// Use chunk for large datasets
User::chunk(1000, function ($users) {
foreach ($users as $user) {
// Process user
}
});
// Use lazy collection for memory efficiency
User::lazy()->each(function ($user) {
// Process user
});
// Use indexes in migration
$table->index(['user_id', 'created_at']);
$table->unique('email');
// Use count instead of getCollection->count()
$count = User::where('active', true)->count();
Database Indexes
// database/migrations/xxxx_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained()->cascadeOnDelete();
$table->string('slug')->unique();
$table->string('title');
$table->text('content');
$table->timestamp('published_at')->nullable();
$table->timestamps();
// Indexes for common queries
$table->index(['author_id', 'published_at']);
$table->index('published_at');
});
// Composite index for sorting
$table->index(['status', 'created_at']);
Authentication with Laravel Sanctum
// config/sanctum.php
'expiration' => 60, // Token expires in 60 minutes
// Controller
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
class AuthController extends Controller
{
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (!Auth::attempt($request->only('email', 'password'))) {
return response()->json(['error' => 'Invalid credentials'], 401);
}
$token = $request->user()->createToken('auth-token')->plainTextToken;
return response()->json(['token' => $token]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
}
Authorization with Policies
// app/Policies/PostPolicy.php
class PostPolicy
{
public function view(User $user, Post $post)
{
return $post->published_at !== null || $user->id === $post->author_id;
}
public function update(User $user, Post $post)
{
return $user->id === $post->author_id;
}
public function delete(User $user, Post $post)
{
return $user->id === $post->author_id || $user->isAdmin();
}
}
// In Controller
public function update(Post $post)
{
$this->authorize('update', $post);
$post->update(request()->validated());
return new PostResource($post);
}
CSRF Protection
// Handle API routes that don't need CSRF
// app/Http/Middleware/VerifyCsrfToken.php
class VerifyCsrfToken extends Middleware
{
protected $except = [
'api/*', // Exclude all API routes
'stripe/*', // Exclude webhook endpoints
];
}
Input Validation and Sanitization
// app/Http/Requests/StorePostRequest.php
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
'tags' => ['array', 'max:5'],
'tags.*' => ['exists:tags,id'],
'excerpt' => ['nullable', 'string', 'max:500'],
];
}
public function prepareForValidation()
{
$this->merge([
'title' => strip_tags($this->title),
'excerpt' => strip_tags($this->excerpt),
]);
}
}
SQL Injection Prevention
// Laravel's query builder and Eloquent automatically prevent SQL injection
// Good - Using parameterized queries
DB::table('users')->where('email', $email)->first();
User::where('email', $email)->first();
// Bad - Never do this
DB::select("SELECT * FROM users WHERE email = '$email'");
// For raw queries, use bindings
DB::select('SELECT * FROM users WHERE email = :email', ['email' => $email]);
Mass Assignment Protection
// app/Models/User.php
class User extends Model
{
protected $fillable = ['name', 'email', 'password'];
// OR use guarded
protected $guarded = ['id', 'is_admin'];
}
Caching with Redis
// Cache query results
$users = Cache::remember('users.all', 3600, function () {
return User::with(['posts', 'roles'])->get();
});
// Cache with tags
Cache::tags(['posts', 'user:' . $userId])->remember("user.{$userId}.posts", 3600, function () use ($userId) {
return Post::where('user_id', $userId)->get();
});
// Clear cache on update
class PostObserver
{
public function updated(Post $post)
{
Cache::forget("post.{$post->id}");
Cache::tags(['posts'])->flush();
}
}
Query Optimization
// Use eager loading
$posts = Post::with(['author', 'tags', 'comments'])->get();
// Select specific columns
$users = User::select(['id', 'name', 'email'])->get();
// Use pagination
$posts = Post::with(['author'])->paginate(20);
// Chunk large results
DB::table('users')->orderBy('id')->chunk(1000, function ($users) {
foreach ($users as $user) {
// Process user
}
});
// Use lazy collections
User::lazy()->each(function ($user) {
// Process user without loading all into memory
});
Queue for Long-Running Tasks
// jobs/SendWelcomeEmail.php
class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 120;
public function __construct(public User $user) {}
public function handle(MailService $mail): void
{
$mail->sendWelcomeEmail($this->user);
}
}
// Dispatch job
SendWelcomeEmail::dispatch($user);
Route Caching
# Cache routes for production
php artisan route:cache
# Clear cache when routes change
php artisan route:clear
Config Caching
# Cache config for production
php artisan config:cache
# Clear cache
php artisan config:clear
Artisan Commands for Maintenance
# Clear all caches
php artisan optimize:clear
# Optimize for production
php artisan optimize
# Compile views
php artisan view:cache
| Version | Status | PHP Required | Key Features |
|---|---|---|---|
| 12.x | Latest (Feb 2025) | 8.2+ | Queue batches 2.0, typed properties, health checks, streamlined structure |
| 11.x | Stable (Mar 2024) | 8.2+ | Slimmed application structure, per-second rate limiting, improved health |
| 10.x | Stable (Feb 2023) | 8.1+ | Native types, process helpers, Horizon & Scout improvements |
// v12: New batch API
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch([
new Job1(),
new Job2(),
])->then(function (Batch $batch) {
// All jobs complete
})->catch(function (Batch $batch, Throwable $e) {
// First failure
})->finally(function (Batch $batch) {
// All jobs complete
})->name('Process Order')->dispatch();
<!-- 🆕 v12: Typed model properties default -->
// v12+: Models have typed properties by default
class User extends Model
{
public string $name;
public string $email;
protected $fillable = ['name', 'email'];
}
<!-- 🆕 v12: Improved health checks -->
// routes/web.php - v12 health endpoint
use Illuminate\Support\Facades\Health;
Health::check('database', fn () => DB::connection()->getPdo());
Health::check('redis', fn () => Redis::connection()->ping());
Health::check('storage', fn () => is_writable(storage_path()));
// Automatic /health endpoint registered
<!-- 🔄 v12: Application structure changes -->
# v12: Even slimmer structure
# - No bootstrap/app.php (config handled automatically)
# - routes/ consolidated
# - Simplified configuration
# v11: Reduced default files
# - No HTTP/Kernel.php, Console/Kernel.php
# - Merged exceptions handling
# - Fewer config files
<!-- 🆕 v11: Per-second rate limiting -->
// v11+: Per-second rate limiting
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return Limit::perSecond(10)->by($request->user()?->id ?: $request->ip());
});
<!-- 🆕 v11: Once helper for lazy evaluation -->
// v11+: Once helper for expensive operations
use function Illuminate\Support\once;
$user = once(fn () => User::firstWhere('email', '[email protected]'));
<!-- 🆕 v11: Improved health checks -->
// v11+: Health checks built-in
php artisan make:command HealthCheckCommand
// Built-in health endpoint available
GET /up
// v10+: All framework code has native type hints
// Application code should also use types
public function store(StorePostRequest $request): JsonResponse
{
return response()->json(['data' => $post], 201);
}
<!-- 🔄 v10: Process helpers -->
// v10+: New process helpers
use Illuminate\Support\Facades\Process;
$result = Process::run('ls -la');
if ($result->successful()) {
echo $result->output();
}
<!-- 🚫 v10: PHP 8.0 minimum required -->
<!-- 🚫 v10: Several deprecated features removed -->
Routing
<!-- ✅ v10+: All routing patterns stable -->// Stable across v10, v11, v12
Route::apiResource('posts', PostController::class);
Route::middleware('auth:sanctum')->group(function () {
// Routes
});
Eloquent Models
<!-- ✅ v10+: All model patterns stable --> <!-- 🆕 v12: Typed properties encouraged -->// v12+: Use typed properties
class Post extends Model
{
public string $title;
public string $content;
protected $fillable = ['title', 'content'];
public function author(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Migrations
<!-- ✅ v10+: All migration patterns stable -->// Stable across versions
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->timestamps();
});
Queues
<!-- ✅ v10+: Job patterns stable --> <!-- 🆕 v12: Improved batch API -->// v12+: Better batch API
$batch = Bus::batch([
new ProcessOrder($order),
new SendNotification($order),
])->allowFailures()->dispatch();
Validation
<!-- ✅ v10+: Form Request patterns stable -->// Stable across versions
class StorePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
];
}
}
Sanctum Authentication
<!-- ✅ v10+: Sanctum API stable -->// Stable across versions
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('users', UserController::class);
});
Application Structure
<!-- 🔄 v11: Slimmed structure --> <!-- 🔄 v12: Further simplification --># v10: Traditional structure
app/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ └── Kernel.php
# v11+: No Kernel.php, merged middleware
# v12+: Even simpler bootstrap, fewer config files
Testing
<!-- ✅ v10+: All testing patterns stable -->// Stable across versions
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_create_post(): void
{
$response = $this->postJson('/api/posts', [
'title' => 'Test',
]);
$response->assertStatus(201);
}
}
From 10 to 11:
composer require laravel/framework:^11.0From 11 to 12:
composer require laravel/framework:^12.0| Version | PHP | Release Date | Support Until |
|---|---|---|---|
| 12.x | 8.2+ | Feb 2025 | Aug 2026 |
| 11.x | 8.2+ | Mar 2024 | Aug 2025 |
| 10.x | 8.1+ | Feb 2023 | Feb 2025 |
Laravel follows an annual release cycle with major releases in February:
Each version receives bug fixes for 18 months and security fixes for 2 years.
Part of SuperAI GitHub - Centralized OpenCode Configuration
xiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience