Pam Native renders real Android views from persistent PHP. PHP owns application state and events, Rust performs retained layout and incremental diffing, and Kotlin mounts bounded mutation batches on the Android UI thread.
composer require pushinbr/pam-native:^0.5For a complete project:
pam init hello-native --template mobile
cd hello-native
pam composer install
pam mobile doctor .
pam mobile dev .A screen can be a compact .pam.php single-file component:
final class Home extends \Pam\Native\Component
{
#[\Pam\Native\Attributes\State]
public int $count = 0;
public function increment(): void
{
$this->count++;
}
}
?>
<template>
<Screen>
<SafeAreaView class="flex-1 surface">
<Column class="flex-1 p-6 gap-4">
<Text class="text-primary" fontSize="28">Hello, PHP</Text>
<Button class="accent" height="52" @press="increment">
Count: {{ $count }}
</Button>
</Column>
</SafeAreaView>
</Screen>
</template>Call App::components(__DIR__.'/src') before App::run(...). Constructor
properties become typed props; named/default slots, p-if, p-for, dynamic
bindings, component events, two-way bindings, and lifecycle hooks all compile
to the existing Element tree and binary protocol. The fluent tree API remains
the default and can be mixed with single-file components freely.
Use pam mobile make:screen, make:component, and make:native-view for
non-destructive scaffolding. Templates support props, slots, events, model
binding, conditional blocks, loops, utility classes and user-defined theme
tokens. The fluent PHP element API and custom Kotlin views remain available as
escape hatches, so the template/class convention is optional.
Safe-area and keyboard avoidance remain native:
<SafeAreaView edges="top,bottom" mode="margin">
<KeyboardAvoidingView
behavior="padding"
keyboardVerticalOffset="24"
enabled="true"
>
<Input placeholder="Message" />
</KeyboardAvoidingView>
</SafeAreaView>Android applies system-bar and IME insets directly on the UI thread. The same
options are available through SafeAreaView::edges()->mode() and
KeyboardAvoidingView::verticalOffset()->avoidingEnabled().
When custom chrome must size itself around those regions,
DeviceInfo::get() exposes safeAreaTop, safeAreaRight, safeAreaBottom,
and safeAreaLeft in logical points plus the device's IANA timeZone identifier
on Android and iOS.
Pull-to-refresh is configured with RefreshControl::colors(),
progressBackgroundColor(), progressViewOffset(), enabled() and size().
The Android gesture and indicator run locally; only onRefresh crosses into
PHP.
Text exposes selectable(), selectionColor(), ellipsize(),
allowFontScaling(), maxFontSizeMultiplier(), adjustsFontSizeToFit(),
breakStrategy(), hyphenation() and dataDetector(). Android performs
selection, fitting, line breaking and link detection inside TextView.
Android project fonts can be bundled in the application source and loaded through an asset family:
/* src/app.css */
@font-face {
font-family: "Brand";
src: url("asset://assets/fonts/Brand-Bold.ttf");
font-weight: 700;
}
Text {
font-family: "Brand";
}<template>
<Text class="brand-title">Brand title</Text>
</template>
<style scoped>
.brand-title {
font-weight: 700;
font-size: 20px;
}
</style>The path resolves below the packaged pam/ asset root. Declare another
@font-face for each weight or italic variant, then author ordinary
font-family, font-weight, and font-style rules. PAM preserves the logical
family through inherited text styles and selects the closest face for each
element, so there is no font registry, CSS parser, or selector work in the
native application runtime. PAM accepts TTF and OTF files, rejects traversal,
caches decoded native typefaces, and keeps ordinary installed family names such
as sans-serif working unchanged. The Rust layout engine also reads and caches
the selected asset face before the first mount, so intrinsic width and wrapping
use its real glyph advances without a UI-thread measurement or corrective
second render.
The conventional src/app.css sheet is prepended automatically to every PAM
component. Use it for fonts, design tokens, tag defaults, and reusable classes;
local <style scoped> rules win the cascade. Relative .css imports are
expanded recursively from the file that declares them at compile time and
invalidate compiled component caches when a dependency changes. Imports cannot
leave the Composer project or load network resources.
Scoped styles are compiled into typed native properties and add no CSS runtime
or selector pass. Tag rules form the base, matching classes follow stylesheet
source order regardless of class order in markup, and authored PAM attributes
win last. Text color, typography, spacing, alignment, and case inherit through
native layout containers and nested .pam.php components. PAM carries that
inheritance as private render context instead of public component props, so
strict constructors receive only attributes authored on their component tag.
Auto-width text uses the parent's relevant flex axis to align visible glyphs:
cross-axis alignment in columns and main-axis justification in rows. Since
0.5.84, packaged fonts use their own cached advances instead of the generic
sans-serif estimator, so labels neither clip nor shift a centered text-and-icon
control under platform font scaling. Explicit widths and growing text preserve
normal start alignment unless text-align is authored.
On Android 0.5.85+, native frames round their absolute start and end edges
before deriving pixel width or height. This keeps centered labels and icons on
one physical center at fractional densities and gives adjacent siblings one
shared edge instead of independently rounded geometry.
On Android 0.5.89+, SafeAreaView intersects stable system-bar and
display-cutout insets with each view's real window bounds. It therefore uses
zero duplicate padding in decor-fitted or nested views, full protection
edge-to-edge, and only the physically overlapping edges in bottom-bar,
mixed/translucent and rotated layouts.
Colors follow CSS syntax inside stylesheets: all named colors, transparent,
short and long hex (including CSS #RGBA/#RRGGBBAA alpha order),
rgb()/rgba(), and hsl()/hsla(). Custom properties support nested
references and var(--name, fallback). PAM also compiles percentages, rem
(16 logical points), physical and logical box shorthands, inset,
transform, object-fit, visibility, box-sizing: border-box,
aspect-ratio, native flex-wrap, percentage opacity, border: none, and
native text decoration. border and its directional forms accept
<width> solid <color>; directional colors currently resolve to the shared
native border color. Absolute position edges accept percentages and
border-radius accepts one to four circular corner values. Unknown web-only
CSS fails compilation with the component path.
Uniform borders additionally accept border-style: solid, dashed, or
dotted; Android and iOS render the pattern natively around rounded corners.
box-shadow accepts one native shadow as
x-offset y-offset [blur-radius] [spread-radius] [color] and none.
Android and iOS render the typed shadow directly; inset and multiple shadows
remain intentionally unsupported.
Template StatusBar accepts backgroundColor/barStyle as aliases for
color/appearance, plus animated and translucent.
On Android, retained stack routes contribute status-bar configuration only
while active, so a hidden or previously mounted screen cannot override the
visible route.
Direct PAM color attributes retain the original #AARRGGBB eight-digit format
for source compatibility. Prefer stylesheet colors when authoring CSS-style
#RRGGBBAA; direct attributes also accept named colors, transparent, short
hex, and CSS color functions.
Template bindings support safe numeric +, -, *, /, integer %, and
parentheses with ordinary precedence. PHP . concatenation accepts scalar,
null, and Stringable operands while rejecting arrays. PHP ?? supplies a
right-associative fallback for null or missing nested paths; the restricted
interpreter does not use eval.
Conditional component roots may use p-if; a false root becomes an inert
invisible placeholder with no layout footprint.
For a custom Android gallery, System\MediaLibrary::assets() reads paginated
image/video metadata and System\MediaLibrary::albums() reads album summaries
on a native worker. Recent assets use descending added time and then descending
modified time, matching Android CameraRoll ordering when captures share the
same added timestamp. Thumbnail content:// sources are not copied. After the
user selects one, System\Files::importUri() materializes only that asset as a
sandboxed FileReference. Request PermissionKind::Photos first and accept
both granted and limited access; use Files::pick() as the portable fallback.
UI\DrawingCanvas captures freehand brush and eraser strokes directly in the
native view. Pointer moves never enter PHP; one bounded, normalized drawing
document is emitted after each completed stroke. Increment request tokens to
undo or clear without making either command controlled state:
<DrawingCanvas
:source="$previewSource"
:value="$drawing"
brushColor="#FFFFFFFF"
brushWidth="6"
drawingMode="brush"
:undoRequest="$undoRequest"
:clearRequest="$clearRequest"
on:change="updateDrawing"
/>System\ImageEditor::render() crops, rotates, flips, filters, adjusts, and
composes imported images and native drawings on a dedicated native worker. Set maxWidth and
maxHeight to bound the encoded dimensions and outputQuality from 1 to 100.
Android reads image bounds first and chooses a decode sample before allocating
the bitmap, then performs one final high-quality resize. This keeps profile
photos and message attachments off the UI thread without decoding their full
camera resolution unnecessarily:
ImageEditor::render(
source: $selected,
cropRatio: ImageCropRatio::Square,
filter: ImageFilterType::Original,
quarterTurns: 0,
flipHorizontal: false,
overlayText: '',
callback: fn (?FileReference $image, string $error) => $this->upload($image),
maxWidth: 1080,
maxHeight: 1080,
outputQuality: 92,
drawing: $drawing,
);Format and migrate a component tree with the package binary:
vendor/bin/pam-native-format src
vendor/bin/pam-native-format --check srcThe formatter removes empty scoped-style blocks and makes p-if,
p-else-if, p-else, and p-for canonical. Legacy v-* directives remain
deprecated compatibility aliases.
Input and its TextInput alias keep composition, selection and the editable
buffer inside a dedicated Android EditText. They support React
Native-compatible capitalization, correction, input mode, autofill,
controlled selection, cursor/underline colors, read-only behavior, return-key
labels, multiline sizing and submit behavior. onSelectionChange is
coalesced once per frame; onContentSizeChange, onKeyPress and
onEndEditing cross into PHP only when registered.
Modal exposes animationType, backdrop/transparency, hardware acceleration,
system-bar translucency and typed request-close/show/dismiss/orientation
callbacks. Android owns its window lifecycle and animation and restores the
previously focused view after a controlled close.
Images use one cancelable loader for Image and ImageBackground. Remote
originals are coalesced and cached on disk, decoded bitmaps are cached in RAM
by measured-size bucket, and Android downsamples before allocating pixels:
Image::make($url)
->defaultSource('asset://avatar-placeholder.png')
->fit(ImageFit::Cover)
->resizeMethod(ImageResizeMethod::Auto)
->resizeMultiplier(2)
->cache(ImageCachePolicy::ForceCache)
->fadeDuration(180)
->onProgress($updateProgress)
->onLoad($rememberNaturalSize)
->onError($showFallback);PAM templates accept cachePolicy="memory-disk" (and memoryDisk) as familiar
aliases for the same native memory-plus-disk behavior as force-cache. This
keeps ports from Expo Image explicit without introducing a JavaScript image
pipeline.
Use cachePolicy="none" (or ImageCachePolicy::None) for a one-shot network
request that bypasses decoded-memory, HTTP and PAM media-disk caches and does
not write the response back to them. This is intended for explicit recovery
after a cached remote image fails; ordinary images should keep a cache-enabled
policy.
asset:// always starts at the PAM project root. For example,
asset://assets/logos/brand.png loads
assets/logos/brand.png on both Android and iOS. Keep the internal pam/
bundle directory out of application code; the runtime adds it consistently
for images, image backgrounds, placeholders, and packaged fonts.
HTTPS, debug HTTP, asset:, file:, content:, android.resource: and
bounded image data: URIs are supported. Redirects cannot downgrade HTTPS;
responses, headers, redirects, input bytes and decoded pixels are bounded.
srcSet, request headers, loading indicators, repeat mode and typed
load-start/progress/load/error/load-end callbacks share the same path.
Callbacks are opt-in, and download progress is coalesced to one event per
display frame before crossing into PHP.
StatusBar::animated(), StatusBar::translucent() and Android
StatusBar::navigationBarHidden() complement color, icon
appearance and visibility. Multiple mounted bars merge in order and restore
the previous native window state when removed. Android 15+ follows mandatory
edge-to-edge semantics. Active Android modal windows receive the same resolved
configuration, including full-screen modal dialogs.
Both scroll directions use the same core host:
Scroll::make($content)
->horizontal()
->contentOffset(x: 120)
->pagingEnabled()
->snapToInterval(320)
->nestedScrollEnabled()
->overScrollMode(ScrollOverScrollMode::Never)
->keyboardDismissMode(ScrollKeyboardDismissMode::OnDrag)
->onScroll($rememberOffset);Declarative components use ScrollView with direct children. PAM inserts the
correct native content container, so compact horizontal items keep their
authored widths and loops may render any number of children:
<ScrollView horizontal="true" showsHorizontalScrollIndicator="false">
<Pressable
p-for="$story in $stories"
:key="$story->id"
width="66"
>
<Image :source="$story->avatar" width="66" height="66" />
</Pressable>
</ScrollView>Horizontal ScrollView content is a native Row; vertical content is a native
Column. The lower-level Scroll::make($content) API deliberately retains its
single explicit content element contract.
For chat timelines, use native end anchoring instead of a guessed content offset:
Scroll::make($messages)
->anchorToEnd()
->maintainVisibleContentPosition()
->autoScrollToEndThreshold(32);Persisted readers can restore a previously observed logical offset through the same tokenized request path. The request runs once when its token changes and does not turn the scroll into a continuously controlled component:
Scroll::make($messages)
->anchorToEnd()
->scrollRequest(
request: $restoreGeneration,
targetOffset: $savedOffset,
);In a .pam.php template use scrollTargetOffset beside scrollRequest.
scrollTargetTestId wins when both targets are present; a negative offset with
an empty target keeps the original scroll-to-end behavior. These tokenized
requests also apply to VirtualizedList, VirtualGrid, and SectionList,
including variable-height cells and targets outside the mounted window.
Set scrollTargetAlignment="center" or "end" to position an identified target
within the viewport; "start" remains the backward-compatible default.
Android owns drag, fling, snapping, fading edges, scrollbars and IME dismissal.
When onScroll is present PAM sends only the active-axis offset, coalesced once
per display frame. ActivityIndicator exposes animating(),
hidesWhenStopped(), size() and color(); Toggle exposes native off/on
track and thumb colors.
Virtualized lists and grids are real AndroidX RecyclerView hosts. Rich cells
accept complete PAM component trees, including images, pressables, inputs and
custom native views:
use Pam\Native\UI\{Column, Image, Pressable, Text, VirtualGrid};
$cells = array_map(
fn (Photo $photo) => Pressable::make(
Column::make(
Image::make($photo->url),
Text::make($photo->title),
),
)
->key((string) $photo->id)
->onPress(fn () => $this->open($photo->id)),
$this->photos,
);
VirtualGrid::make(2, ...$cells)
->rowHeight(224)
->prefetch(8)
->onEndReached($loadMore);
SectionList::make($groups)
->rowHeight(48)
->inverted()
->onScroll($rememberOffset);horizontal(), columns(), inverted(), initialScrollIndex(),
removeClippedSubviews(), scrollEnabled() and showsIndicator() map directly
to the native host. Packed scalar and section payloads remain outside PHP while
scrolling; Android mounts only visible/prefetched rich cells, preserves keyed
identity and event routing, and limits onScroll to one event per VSYNC.
For non-virtualized responsive screens, Grid::make(...$children) provides a
12-column retained grid with gutters, spans, offsets, ordering and mobile-first
sm/md/lg/xl breakpoints. See docs/components.md for fluent and tag
examples.
Run pam mobile benchmark . on a physical device for release-like AndroidX
Macrobenchmarks, and pam mobile profile . to generate the Baseline Profile
independently. Protocol v1 compatibility and limits are documented in
PROTOCOL.md.
Http::get() remains available for simple reads. Authenticated APIs can use the
generic request API or the post(), put(), patch() and delete() helpers:
use Pam\Native\Http\Http;
use Pam\Native\Http\HttpResponse;
Http::json(
method: 'POST',
url: 'https://api.example.com/login',
data: ['email' => $email, 'password' => $password],
callback: function (HttpResponse $response): void {
if ($response->transportFailed()) {
// Status 0; inspect $response->error and keep offline work queued.
return;
}
// Read $response->statusCode, $response->body and $response->successful().
},
);
Http::request(
method: 'PATCH',
url: 'https://api.example.com/profile',
callback: fn (HttpResponse $response) => $this->updated($response),
headers: [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
],
body: json_encode(['name' => $name], JSON_THROW_ON_ERROR),
);Production builds require HTTPS. Requests support GET, POST, PUT, PATCH and
DELETE, up to 32 bounded single-line headers, a one MiB request body and timeout
values from one to 120 seconds. DNS, connectivity and timeout failures reach the
same callback with status 0; they never crash the component runtime.
Free and open-source under the Apache License 2.0. You may use, modify, and distribute this package for any purpose, including commercially.