PHPackages                             toppy/async-view-model - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. toppy/async-view-model

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

toppy/async-view-model
======================

Framework-agnostic async view model resolution with AmPHP Fibers

v0.7.5(2w ago)091↓86.7%2proprietaryPHPPHP &gt;=8.4

Since Jan 23Pushed 2w agoCompare

[ Source](https://github.com/toppynl/async-view-model)[ Packagist](https://packagist.org/packages/toppy/async-view-model)[ RSS](/packages/toppy-async-view-model/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (15)Versions (17)Used By (2)

Async View Model
================

[](#async-view-model)

> **Read-Only Repository**This is a read-only subtree split from the main repository. Please submit issues and pull requests to [toppynl/symfony-astro](https://github.com/toppynl/symfony-astro).

Framework-agnostic async view model resolution with AmPHP Fibers. This is Layer 0 (core) of the Toppy Stack - a foundation for parallel data fetching that integrates with any PHP framework supporting PSR containers.

Installation
------------

[](#installation)

```
composer require toppy/async-view-model
```

Requirements
------------

[](#requirements)

- PHP 8.4+
- [amphp/amp](https://github.com/amphp/amp) ^3.0 - Fiber-based async primitives
- [amphp/http-client](https://github.com/amphp/http-client) ^5.0 - Async HTTP client
- [psr/container](https://github.com/php-fig/container) ^1.1 || ^2.0 - Service container interface
- [psr/log](https://github.com/php-fig/log) ^1.0 || ^2.0 || ^3.0 - Logging interface

Quick Start
-----------

[](#quick-start)

```
use Amp\Future;
use Toppy\AsyncViewModel\AsyncViewModel;
use Toppy\AsyncViewModel\Context\RequestContext;
use Toppy\AsyncViewModel\Context\ViewContext;

// 1. Define your data class
final readonly class ProductStock
{
    public function __construct(
        public int $quantity,
        public bool $inStock,
    ) {}
}

// 2. Implement AsyncViewModel
final class ProductStockViewModel implements AsyncViewModel
{
    public function __construct(
        private readonly StockApiClient $api,
    ) {}

    /**
     * @return Future
     */
    public function resolve(ViewContext $viewContext, RequestContext $requestContext): Future
    {
        $productId = $requestContext->get('productId');

        // Returns immediately - actual HTTP request runs in a Fiber
        return $this->api->getStockAsync($productId);
    }
}

// 3. Resolve via ViewModelManager
$manager->preload(ProductStockViewModel::class);
// ... render template shell ...
$stock = $manager->get(ProductStockViewModel::class); // Blocks only when data is accessed
```

Architecture
------------

[](#architecture)

### Key Classes

[](#key-classes)

ClassPurpose`AsyncViewModel`Core interface - implementations return `Future` from `resolve()``ViewModelManager`Orchestrates preloading, dependency ordering, and lazy proxy creation`ViewContext`Immutable user/session state (currency, locale, B2B flag) - safe for Fibers`RequestContext`Immutable route parameters with polymorphic `fromArray()` deserialization`WithDependencies`Interface for ViewModels that depend on other ViewModels`DependencyGraph`DAG-based topological sorting with priority by dependent count`CacheableViewModel`Interface for SWR caching with TTL semantics`ResetInterface`Worker mode support - reset state between requests`ViewModelProfilerInterface`Timing and parallel efficiency metrics collection### Directory Structure

[](#directory-structure)

```
Toppy/Component/AsyncViewModel/
├── AsyncViewModel.php              # Core interface
├── AsyncIslandProviderInterface.php # Island provider contract
├── ViewModelManager.php            # Resolution orchestrator
├── ViewModelManagerInterface.php   # Manager contract
├── WithDependencies.php            # Dependency declaration interface
├── DependencyGraph.php             # Topological sort implementation
├── ResetInterface.php              # Worker mode reset contract
├── WithCacheMetadata.php           # Cache metadata interface
├── CacheMetadataBehaviour.php      # Cache metadata trait
├── Context/
│   ├── ViewContext.php             # Immutable user/session state
│   ├── RequestContext.php          # Immutable route parameters
│   ├── ContextFactoryInterface.php # Context creation contract
│   └── ContextResolverInterface.php # Context resolution contract
├── Cache/
│   ├── CacheableViewModel.php      # SWR caching interface
│   ├── CacheEntry.php              # Cache entry value object
│   ├── CachingViewModelDecorator.php # Caching decorator
│   ├── SwrCacheInterface.php       # Stale-while-revalidate cache contract
│   └── RevalidationLockInterface.php # Distributed lock for revalidation
├── Exception/
│   ├── NoDataException.php         # Data not available
│   ├── ViewModelNotPreloadedException.php # Preload required error
│   └── ViewModelResolutionException.php # Resolution failure
├── Profiler/
│   ├── ViewModelProfilerInterface.php # Profiler contract
│   ├── NullViewModelProfiler.php   # No-op implementation for production
│   ├── TimeEpoch.php               # Shared time reference
│   ├── TimelineEntry.php           # Resolution timing data
│   ├── HttpClientProfilerInterface.php # HTTP profiler contract
│   ├── NullHttpClientProfiler.php  # No-op HTTP profiler
│   └── HttpRequestEntry.php        # HTTP request timing data
├── Http/
│   └── ProfilingApplicationInterceptor.php # AmPHP HTTP client interceptor
├── Tests/
│   ├── Unit/                       # Unit tests
│   └── Fixtures/                   # Test doubles
└── composer.json

```

Usage
-----

[](#usage)

### Creating a View Model

[](#creating-a-view-model)

View models implement `AsyncViewModel` and return a `Future` from `resolve()`. The PHPDoc `@return Future` is required for lazy proxy creation.

```
use Amp\Future;
use Toppy\AsyncViewModel\AsyncViewModel;
use Toppy\AsyncViewModel\Context\RequestContext;
use Toppy\AsyncViewModel\Context\ViewContext;

final readonly class UserProfileData
{
    public function __construct(
        public string $name,
        public string $email,
        public string $avatarUrl,
    ) {}
}

/**
 * @implements AsyncViewModel
 */
final class UserProfileViewModel implements AsyncViewModel
{
    public function __construct(
        private readonly UserApiClient $api,
    ) {}

    /**
     * @return Future
     */
    #[\Override]
    public function resolve(ViewContext $viewContext, RequestContext $requestContext): Future
    {
        $userId = $requestContext->get('userId');

        // Non-blocking - starts HTTP request in a Fiber
        return $this->api->fetchUserAsync($userId)->map(
            fn(array $data) => new UserProfileData(
                name: $data['name'],
                email: $data['email'],
                avatarUrl: $data['avatar_url'],
            )
        );
    }
}
```

### Context Objects

[](#context-objects)

Both context objects are **immutable** and safe to pass to background Fibers.

#### ViewContext - User/Session State

[](#viewcontext---usersession-state)

```
use Toppy\AsyncViewModel\Context\ViewContext;

// Create from session/request data
$viewContext = ViewContext::create(
    currency: 'EUR',
    locale: 'en_GB',
    isB2B: false,
    isVatExempt: false,
    customerGroup: 'retail',
    isPrivate: false, // Whether response is cacheable
);

// Access in ViewModel
public function resolve(ViewContext $viewContext, RequestContext $requestContext): Future
{
    $currency = $viewContext->getCurrency(); // 'EUR'
    $locale = $viewContext->getLocale();     // 'en_GB'

    if ($viewContext->isB2B()) {
        // B2B-specific logic
    }
}
```

#### RequestContext - Route Parameters

[](#requestcontext---route-parameters)

```
use Toppy\AsyncViewModel\Context\RequestContext;

// Create from route parameters
$requestContext = RequestContext::create(
    params: ['productId' => 123, 'categorySlug' => 'electronics'],
    requestId: 'req_abc123',
);

// Access in ViewModel
public function resolve(ViewContext $viewContext, RequestContext $requestContext): Future
{
    $productId = $requestContext->get('productId');     // 123
    $category = $requestContext->get('categorySlug');   // 'electronics'
    $missing = $requestContext->get('foo', 'default');  // 'default'
    $all = $requestContext->all();                      // Full params array
}

// Serialization for encrypted URL transport
$serialized = $requestContext->toArray();
// ['_type' => 'Toppy\AsyncViewModel\Context\RequestContext', 'params' => [...], 'requestId' => '...']

$restored = RequestContext::fromArray($serialized);
```

### Resolution with ViewModelManager

[](#resolution-with-viewmodelmanager)

The `ViewModelManager` orchestrates async resolution with these key features:

1. **Non-blocking preload** - Starts Futures immediately, doesn't wait
2. **Dependency ordering** - ViewModels with the most dependents start first
3. **Lazy proxies** - `get()` returns a proxy that blocks only on first property access
4. **Deduplication** - Same class preloaded twice returns same Future

```
use Toppy\AsyncViewModel\ViewModelManager;
use Toppy\AsyncViewModel\Profiler\NullViewModelProfiler;

// Setup (typically done by DI container)
$manager = new ViewModelManager(
    viewModels: $container,  // PSR ContainerInterface with registered ViewModels
    profiler: new NullViewModelProfiler(),
    contextResolver: $contextResolver,
);

// Preload single ViewModel (non-blocking)
$manager->preload(ProductStockViewModel::class);

// Preload multiple ViewModels with automatic dependency discovery
$manager->preloadAll([
    ProductDetailsViewModel::class,
    ProductReviewsViewModel::class,
    RelatedProductsViewModel::class,
]);

// Get data - returns lazy proxy, blocks only on property access
$stock = $manager->get(ProductStockViewModel::class);
echo $stock->quantity; // preloadWithFuture(ProductStockViewModel::class);
$data = $future->await(); // Explicit blocking

// Inspect all tracked ViewModels
$all = $manager->all(); // Returns array of Futures and resolved objects
```

### Declaring Dependencies

[](#declaring-dependencies)

When ViewModels depend on data from other ViewModels, implement `WithDependencies`:

```
use Toppy\AsyncViewModel\WithDependencies;

final class ProductPageViewModel implements AsyncViewModel, WithDependencies
{
    public function __construct(
        private readonly ViewModelManagerInterface $manager,
    ) {}

    /**
     * @return array
     */
    #[\Override]
    public function getDependencies(): array
    {
        return [
            ProductDetailsViewModel::class,
            ProductStockViewModel::class,
        ];
    }

    /**
     * @return Future
     */
    #[\Override]
    public function resolve(ViewContext $viewContext, RequestContext $requestContext): Future
    {
        // Dependencies are guaranteed to have started before this ViewModel
        $detailsFuture = $this->manager->preloadWithFuture(ProductDetailsViewModel::class);
        $stockFuture = $this->manager->preloadWithFuture(ProductStockViewModel::class);

        return Future\all([$detailsFuture, $stockFuture])->map(
            fn(array $results) => new ProductPageData(
                details: $results[0],
                stock: $results[1],
            )
        );
    }
}
```

The `DependencyGraph` performs topological sorting to ensure:

- Dependencies start before dependents
- Circular dependencies are detected with clear error messages
- ViewModels with the most transitive dependents start first (maximizes parallelism)

### SWR Caching

[](#swr-caching)

Implement `CacheableViewModel` for stale-while-revalidate caching:

```
use Toppy\AsyncViewModel\Cache\CacheableViewModel;

final class ProductStockViewModel implements CacheableViewModel
{
    /**
     * @return Future
     */
    #[\Override]
    public function resolve(ViewContext $viewContext, RequestContext $requestContext): Future
    {
        return $this->api->getStockAsync($requestContext->get('productId'));
    }

    #[\Override]
    public function getCacheKey(ViewContext $viewContext, RequestContext $requestContext): string
    {
        return sprintf('stock_%d_%s', $requestContext->get('productId'), $viewContext->getCurrency());
    }

    #[\Override]
    public function getCacheTags(ViewContext $viewContext, RequestContext $requestContext): array
    {
        return ['product_' . $requestContext->get('productId'), 'stock'];
    }

    #[\Override]
    public function getMaxAge(): int
    {
        return 60; // Fresh for 60 seconds
    }

    #[\Override]
    public function getStaleWhileRevalidate(): int
    {
        return 300; // Serve stale for 5 minutes while revalidating async
    }

    #[\Override]
    public function getStaleIfError(): int
    {
        return 3600; // Serve stale for 1 hour if revalidation fails
    }
}
```

### Worker Mode Considerations

[](#worker-mode-considerations)

In worker mode (FrankenPHP, RoadRunner), PHP processes persist across requests. Services holding request-scoped state must implement `ResetInterface`:

```
use Toppy\AsyncViewModel\ResetInterface;

final class ViewModelManager implements ResetInterface
{
    private array $futures = [];
    private array $resolved = [];

    #[\Override]
    public function reset(): void
    {
        $this->futures = [];
        $this->resolved = [];
    }
}
```

When using Symfony, services can implement both this package's `ResetInterface` and Symfony's `Symfony\Contracts\Service\ResetInterface` for automatic reset handling.

### Profiling

[](#profiling)

Implement `ViewModelProfilerInterface` to collect timing data:

```
use Toppy\AsyncViewModel\Profiler\ViewModelProfilerInterface;
use Toppy\AsyncViewModel\Profiler\TimelineEntry;

// Get profiler entries after resolution
$entries = $profiler->getEntries();

foreach ($entries as $entry) {
    echo sprintf(
        "%s: %.2fms (status: %s)\n",
        $entry->getShortName(),
        $entry->getDuration(),
        $entry->status,
    );
}

// Check parallel efficiency (1.0 = perfect parallelism)
$efficiency = $profiler->getParallelEfficiency();
echo "Parallel efficiency: " . ($efficiency * 100) . "%\n";

// Total wall-clock time
echo "Total time: " . $profiler->getTotalTime() . "ms\n";
```

Use `NullViewModelProfiler` in production to avoid profiling overhead.

Integration
-----------

[](#integration)

This package is Layer 0 (core) of the Toppy Stack - it has no framework dependencies and can be used standalone or as a foundation for framework-specific integrations.

```
symfony-async-twig-bundle (Layer 3: Symfony bridge)
        │
   ┌────┴────┐
   ▼         ▼
twig-prerender (Layer 2) ──► twig-streaming (Layer 1)
                                   │
                                   │    twig-view-model (Layer 1)
                                   │         │
                                   └────┬────┘
                                        ▼
                              async-view-model (Layer 0: core) ◄── You are here

```

### Framework Integrations

[](#framework-integrations)

PackagePurpose`toppy/twig-view-model`Twig `view()` function integration`toppy/twig-streaming`Streaming response with deferred slots`toppy/twig-prerender`Twig `{% include %}` modifiers`toppy/symfony-async-twig-bundle`Full Symfony integrationTesting
-------

[](#testing)

```
# Run all tests
./vendor/bin/phpunit

# Run single test file
./vendor/bin/phpunit Tests/Unit/ViewModelManagerTest.php

# Run single test method
./vendor/bin/phpunit --filter testPreloadAllStartsDependenciesFirst
```

### Writing Tests

[](#writing-tests)

Use the `NullViewModelProfiler` and stub containers:

```
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Toppy\AsyncViewModel\Profiler\NullViewModelProfiler;
use Toppy\AsyncViewModel\ViewModelManager;

final class MyViewModelTest extends TestCase
{
    public function testResolution(): void
    {
        $viewModel = new MyViewModel(/* dependencies */);

        $container = $this->createStub(ContainerInterface::class);
        $container->method('has')->willReturn(true);
        $container->method('get')->willReturn($viewModel);

        $contextResolver = $this->createContextResolver();
        $manager = new ViewModelManager($container, new NullViewModelProfiler(), $contextResolver);

        $manager->preload(MyViewModel::class);
        $result = $manager->get(MyViewModel::class);

        static::assertInstanceOf(MyData::class, $result);
    }
}
```

License
-------

[](#license)

Proprietary - see LICENSE file for details.

###  Health Score

46

↑

FairBetter than 92% of packages

Maintenance97

Actively maintained with recent releases

Popularity13

Limited adoption so far

Community14

Small or concentrated contributor base

Maturity51

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 97.3% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~9 days

Recently: every ~2 days

Total

16

Last Release

16d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/239d9adcbadfaac1e3ce531c1b81d87e378c3395b9c10bef5bfddb1637c07c9d?d=identicon)[Swahjak](/maintainers/Swahjak)

---

Top Contributors

[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (36 commits)")[![Swahjak](https://avatars.githubusercontent.com/u/4386577?v=4)](https://github.com/Swahjak "Swahjak (1 commits)")

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/toppy-async-view-model/health.svg)

```
[![Health](https://phpackages.com/badges/toppy-async-view-model/health.svg)](https://phpackages.com/packages/toppy-async-view-model)
```

###  Alternatives

[symfony/symfony

The Symfony PHP framework

31.4k87.2M2.2k](/packages/symfony-symfony)[laravel/framework

The Laravel Framework.

34.8k543.8M20.1k](/packages/laravel-framework)[ecotone/ecotone

Enterprise architecture layer for Laravel and Symfony — CQRS, Event Sourcing, Durable Workflows (Sagas, Orchestrators), Projections, and Outbox messaging via PHP attributes.

564576.7k52](/packages/ecotone-ecotone)[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k15](/packages/tempest-framework)[civicrm/civicrm-core

Open source constituent relationship management for non-profits, NGOs and advocacy organizations.

751291.4k43](/packages/civicrm-civicrm-core)[wikimedia/parsoid

Parsoid, a bidirectional parser between wikitext and HTML5

187557.3k3](/packages/wikimedia-parsoid)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
