PHPackages                             iceylan/subtitle - 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. [Parsing &amp; Serialization](/categories/parsing)
4. /
5. iceylan/subtitle

ActiveLibrary[Parsing &amp; Serialization](/categories/parsing)

iceylan/subtitle
================

A comprehensive subtitle parser and renderer library.

v0.1.0(1mo ago)10MITPHP

Since Jun 15Pushed 1mo agoCompare

[ Source](https://github.com/ismailceylan/php-subtitle-toolkit)[ Packagist](https://packagist.org/packages/iceylan/subtitle)[ RSS](/packages/iceylan-subtitle/feed)WikiDiscussions main Synced 2w ago

READMEChangelog (1)DependenciesVersions (2)Used By (0)

Subtitle Manager for PHP
========================

[](#subtitle-manager-for-php)

[![Latest Version on Packagist](https://camo.githubusercontent.com/3cc7c88c165f8086b932edf9355fe63f252e14303de352c37492d1987dc3c7cf/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f696365796c616e2f7375627469746c652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/iceylan/subtitle) [![Total Downloads](https://camo.githubusercontent.com/595245bb392442bcf7fe7232d5216e79f83de933aa4e66e5cf47108127a773d8/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f696365796c616e2f7375627469746c652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/iceylan/subtitle) [![Software License](https://camo.githubusercontent.com/55c0218c8f8009f06ad4ddae837ddd05301481fcf0dff8e0ed9dadda8780713e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d627269676874677265656e2e7376673f7374796c653d666c61742d737175617265)](LICENSE.md)

`Iceylan\Subtitle` is a modern, enterprise-grade, highly extensible, and **100% Immutable** subtitle manipulation library for PHP. Built on robust software design patterns, it allows you to parse, convert, shift time, change frame rates, and programmatically resolve subtitle overlaps without any unexpected side effects.

Features
--------

[](#features)

- **Fluent API:** Clean and readable method chaining.
- **Smart Formats:** Auto-detects subtitle formats.
- **Immutability:** Keeps your original subtitle data safe from side effects.
- **Overlap Resolution:** Built-in strategies to clean overlapping subtitle timestamps.
- **Format Conversion**: Convert between different subtitle formats.
- **Time Shifting:** Change FPS, stretch by anchors or just move subtitles forward or backward in time.
- **Extensible:** Easily inject custom parsers, renderers, or overlap resolvers.

---

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

[](#installation)

```
composer require iceylan/subtitle
```

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

[](#quick-start)

Convert a SubRip (`.srt`) file into a WebVTT (`.vtt`) string while automatically cleaning up time overlaps and normalizing lines in one single chain:

```
use Iceylan\Subtitle\Parse;
use Iceylan\Subtitle\Renderers\VTT;

// Read SRT, sanitize, clean overlaps, and output as WebVTT string
$content = Parse::from( 'movie.srt' )
    ->sanitize()
    ->resolveOverlaps()
    ->render( VTT::class );

file_put_contents( 'movie.vtt', $content );
```

Architecture &amp; Core Concepts
--------------------------------

[](#architecture--core-concepts)

### 1. Unified Gateway (Parse)

[](#1-unified-gateway-parse)

The `Parse` class acts as a static gateway that utilizes lazy-loading and driver detection. It normalizes heterogeneous line endings (`\r\n`, `\r`) to unified unix lines (`\n`) under the hood before delegating the work to the appropriate parser.

```
use Iceylan\Subtitle\Parse;

// Auto-detects and loads the file into a Collection
$collection = Parse::from( 'path/to/subtitle.srt' );
```

#### Registering Custom Formats

[](#registering-custom-formats)

You can expand the library by adding your own parser drivers without touching the core source code:

```
Parse::register( MyCustomParserDriver::class );
```

### 2. Deep Immutable Engine (`Collection` &amp; `Entry`)

[](#2-deep-immutable-engine-collection--entry)

Every manipulation creates a brand-new timeline universe. Thanks to deep-cloning capability, original instances remain unpolluted.

```
$original = Parse::from( 'movie.srt' );

// Each line creates an independent variation of your timeline
$shifted   = $original->delay( 2500 ); // 2.5 seconds forward
$converted = $original->changeFPS( 23.976, 25.0 );

// $original remains completely untainted in memory!
```

Complete API Reference
----------------------

[](#complete-api-reference)

### Collection Queries

[](#collection-queries)

#### get

[](#get)

`get(int $index): Entry`

Retrieves a specific subtitle cue entry. Supports negative indexes to seamlessly grab entries counting back from the end of the timeline.

```
$firstEntry = $collection->get( 0 );
$lastEntry  = $collection->get( -1 ); // Python-style reverse indexing!
```

Or you can also use the subtitle collection like an array:

```
$firstEntry = $collection[ 0 ];
$lastEntry  = $collection[ -1 ]; // Python-style reverse indexing!
```

#### getEntries

[](#getentries)

`getEntries(): array`

Extracts the underlying raw array containing all Entry instances.

```
$rawArray = $collection->getEntries();
```

#### between

[](#between)

`between(int $startMs, int $endMs): Collection`

Retrieves subtitle cues between two timestamps in milliseconds.

```
$timeWindow = $collection->between( 15000, 80000 );
// between first 15 and 80 seconds
```

#### until

[](#until)

`until(int $endMs): Collection`

Retrieves subtitle cues until a specific timestamp in milliseconds.

```
$firstTenSeconds = $collection->until( 10000 );
```

#### since

[](#since)

`since(int $startMs): Collection`

Retrieves subtitle cues since a specific timestamp in milliseconds.

```
$lastTenSeconds = $collection->since( 10000 );
```

#### count

[](#count)

`count(): int`

Returns the total number of subtitle entries inside the collection.

```
$totalCues = $collection->count();
```

Or you can also use the subtitle collection like an array:

```
$totalCues = count( $collection );
```

#### duration

[](#duration)

`duration(): int`

Calculates the gross chronological timeline span between the very first starting cue and the absolute last ending cue in milliseconds.

```
$totalMovieTimeSpan = $collection->duration();
```

#### characters

[](#characters)

`characters(): int`

Returns the total number of characters across all subtitle cues.

```
$totalCharacters = $collection->characters();
// 75489
```

#### words

[](#words)

`words(): int`

Returns the total number of words across all subtitle cues.

```
$totalWords = $collection->words();
// 12295
```

#### screenTime

[](#screentime)

`screenTime(): int`

Calculates the absolute summation of individual subtitle visibility durations in milliseconds.

```
$activeMilliseconds = $collection->screenTime();
```

#### silentTime

[](#silenttime)

`silentTime(): int`

Calculates the absolute summation of individual subtitle silence durations in milliseconds.

```
$inactiveMilliseconds = $collection->silentTime();
```

#### silenceDensity

[](#silencedensity)

`silenceDensity(): float|int`

Returns the ratio of the silent duration to the duration the text remains on the screen.

```
$silenceRatio = $collection->silenceDensity();
// 0.2485
```

#### talkativeDensity

[](#talkativedensity)

`talkativeDensity(): float|int`

Returns the ratio of the time text remains hidden on the screen to the total visible time.

```
$silenceRatio = $collection->talkativeDensity();
// 4.2042
```

#### avgDurationPerEntry

[](#avgdurationperentry)

`avgDurationPerEntry(): float|int`

Returns the average time (in milliseconds) each entry appears on the screen.

```
$averageDuration = $collection->avgDurationPerEntry();
```

#### avgSilenceBetweenEntries

[](#avgsilencebetweenentries)

`avgSilenceBetweenEntries(): float|int`

Returns the average silence duration (in milliseconds) between subtitle cues.

```
$averageSilence = $collection->avgSilenceBetweenEntries();
// 731.3
```

#### avgCharsPerSecond

[](#avgcharspersecond)

`avgCharsPerSecond(): float|int`

Returns the number of characters per second. This can be considered the average speed of the subtitles.

```
$averageCharactersPerSecond = $collection->avgCharsPerSecond();
// 13.29
```

#### avgWordsPerMinute

[](#avgwordsperminute)

`avgWordsPerMinute(): float|int`

Returns the number of words per minute.

```
$averageWordsPerMinute = $collection->avgWordsPerMinute();
// 130.61
```

### Collection Transformations

[](#collection-transformations)

#### delay

[](#delay)

`delay(int $ms): Collection`

Shifts the entire subtitle track timeline forward or backward by the given amount of milliseconds.

```
$twoSecsForward = $collection->delay( 2000 );
$oneSecBackward = $collection->delay( -1000 );
```

#### delayFrom

[](#delayfrom)

`delayFrom(int $fromIndex, int $ms): Collection`

Shifts timelines by milliseconds exclusively for entries starting from a specific index boundary up to the end.

```
// Shifts everything starting from index 15 and onwards by 3 seconds
$partiallyShifted = $collection->delayFrom( 15, 3000 );
```

#### cut

[](#cut)

`cut(int $fromIndex, int $length = 1): Collection`

Excises a specific range of subtitle cues out of the collection and automatically retrofits the chronologically succeeding blocks back in time to heal the gap.

```
$trimmedCollection = $collection->cut(from: 10, length: 3);
```

#### sanitize

[](#sanitize)

`sanitize(): Collection`

Loops through all textual contents of the cues and strips out any untrusted markup or HTML tags.

```
$cleanTextCollection = $collection->sanitize();
```

#### changeFPS

[](#changefps)

`changeFPS(float $from, float $to): Collection`

Re-calculates all timestamp sequences proportionally to match target frame rate transitions.

```
$palVersion = $collection->changeFPS( 23.976, 25.0 );
```

#### stretch

[](#stretch)

`stretch(int $srcAnchor1, int $desAnchor1, int $srcAnchor2, int $desAnchor2): Collection`

Applies linear time interpolation scaling across the entire timeline using two custom real-time reference anchor coordinates. Perfect for syncing subtitles when the frame rate is unknown.

```
$syncedCollection = $collection->stretch(
    srcAnchor1: 5000,  desAnchor1: 7200,  // first reference point warp
    srcAnchor2: 90000, desAnchor2: 94500  // second reference point warp
);
```

#### filter

[](#filter)

`filter(Closure $callback): Collection`

Filters the collection using a custom boolean evaluation closure. Returns a brand new subset collection.

```
$adsRemoved = $collection->filter( function( Entry $entry )
{
    return ! in_array( '[www.yourads.com](https://www.yourads.com)', $entry->content );
});
```

#### map

[](#map)

`map(Closure $callback): Collection`

Applies a custom callback to each entry in the collection. Returns a brand new subset collection.

```
$toUpperCase = fn( Entry $entry ) => $entry->content = strtoupper( $entry->content );
$upperCased = $collection->map( $toUpperCase );
```

If the callback returns anything other than an Entry object, neither that entry nor the returned value will be added to the final collection.

#### each

[](#each)

`each(Closure $callback): Collection`

Applies a custom callback to each entry in the collection. Returns a brand new collection.

```
$entry10 = $collection->get( 10 );

$collection->each( function( Entry $entry, int $index )
{
    if( $index === 10 )
    {
        if( $entry === $entry10 )
        {
            // this line never gets executed
        }
        else
        {
            exit(
                'The $entry object cannot be modified because it is '.
                'read-only while inside the immutable method'
            );
        }
    }
});
```

#### slice

[](#slice)

`slice(int $startIndex, ?int $length = null): Collection`

Extracts a designated window slice out of the collection sequence.

```
$firstTenLines = $collection->slice(0, 10);
```

#### merge

[](#merge)

`merge(Collection $other): Collection`

Merges two separate collections together and automatically re-sorts them chronologically.

```
$fullSubtitles = $partOneCollection->merge($partTwoCollection);
```

#### append

[](#append)

`append(Collection $other, int $gapMs = 0): Collection`

Appends one collection to the end of another. Optionally adds a safe gap between the two collections. It can be useful for example when you have two separate subtitle tracks that need to be merged together like CD1 and CD2.

```
$CD1 = Parse::from( "movie/cd-1.srt" ); // items 500
$CD2 = Parse::from( "movie/cd-2.srt" ); // items 200

$fullSubtitles = $CD1->append( $CD2, 1000 );

echo $CD1->count(); // 500
echo $fullSubtitles->count(); // 700
```

#### push

[](#push)

`push(Entry $entry) / addEntry(Entry $entry): Collection`

Safely appends a new subtitle element block into the timeline pool and auto-arranges its chronological index placement.

```
use Iceylan\Subtitle\Entry;

$newCue = ( new Entry )
    ->setSequenceNumber( 101 )
    ->setStart( 12000 )
    ->setEnd( 15500 )
    ->addContent( "Breaking News!" );

$updatedCollection = $collection->push( $newCue );
```

#### resolveOverlaps

[](#resolveoverlaps)

`resolveOverlaps(?OverlapsResolverInterface $resolver = null): Collection`

Resolves temporal overlapping collision structural errors where a trailing block's start overlaps a preceding block's end. Uses a default TrimAndGapResolver(10) if no strategy is specified.

```
// Clean up overlaps leaving a safe 10ms gap between colliding items
$resolved = $collection->resolveOverlaps();
```

### Overlap Resolution Strategies

[](#overlap-resolution-strategies)

You can swap out the collision resolution strategy or build your own by implementing `OverlapsResolverInterface`.

#### Built-in Strategy: `TrimAndGapResolver`

[](#built-in-strategy-trimandgapresolver)

This strategy adjusts the end time of the preceding item to create a comfortable gap before the next subtitle block arrives, safely handling narrow intervals or empty text fallbacks.

```
use Iceylan\Subtitle\Resolvers\TrimAndGapResolver;

// Enforces a strict 20ms gap between any overlapping blocks
$resolved = $collection->resolveOverlaps( new TrimAndGapResolver( 20 ));
```

### Network Optimization &amp; Frontend Integration

[](#network-optimization--frontend-integration)

Both `Entry` and `Collection` natively implement `JsonSerializable`. The JSON payload keys are highly compressed to reduce data overhead, making it ideal for directly streaming files over the wire to your Vue.js, React, or modern web video players.

```
echo json_encode( $collection );
```

**Compressed JSON Output Structure:**

```
[
  {
    "sq": 1,
    "ms": [ "Hello, welcome to the film." ],
    "st": 1500,
    "en": 4200
  },
  {
    "sq": 2,
    "ms": [ "Subtitles optimized for network speed." ],
    "st": 4500,
    "en": 8000
  }
]
```

- sq: Sequence Number
- ms: Message / Text Array content lines
- st: Start timestamp in absolute milliseconds
- en: End timestamp in absolute milliseconds

### Extending with Custom Formats

[](#extending-with-custom-formats)

Adding support for an extra subtitle format is extremely straight-forward. Simply implement the core interfaces provided under the `Support` layer.

**Creating a Custom Parser Driver**

```
namespace App\Subtitle\Parsers;

use Iceylan\Subtitle\Collection;
use Iceylan\Subtitle\Support\ParserInterface;

class MyCustomParser implements ParserInterface
{
    public static function canParse( string $content, string $extension ): bool
    {
        return $extension === 'custom';
    }

    public function parse( string $content ): Collection
    {
        $collection = new Collection();
        // Custom parsing state logic here...
        return $collection;
    }
}
```

---

Contributing
------------

[](#contributing)

I welcome all kind of contributions!

The infrastructure is built using the Infrastructure Strategy Pattern. To add a new format, simply write a class that implements `ParserInterface` and `RendererInterface`, place them in the appropriate directories, register them in the `Parser` class, and submit a PR, that’s all you need to do!

Upcoming Planned Features
-------------------------

[](#upcoming-planned-features)

I am committed to making this package the ultimate **Subtitle Swiss Army Knife** for PHP. Here are the enterprise-grade concepts and features currently in active planning. If you want to contribute to any of these, feel free to open an Issue or PR!

- **Subtitle Validator**
    - An automated quality control engine to validate structural correctness before rendering or uploading.
    - *Features:* Check for empty cue blocks, flag hyper-short durations (e.g., cues under 300ms), detect character-per-line (CPL) threshold violations, and warn if an entry exceeds industry-standard safe line counts (e.g., max 2 lines).
- **Smart HTTP Output &amp; Streaming Drivers**
    - Seamless integration out of the box for modern frameworks like Laravel and Symfony to handle file delivery.
    - *Features:* Direct-to-browser attachment download wrappers, automated server-side content-type handling (`text/vtt`, `application/x-subrip`), and memory-optimized stream transmitters for massive subtitle files.
- **Bulletproof Encoding &amp; Charset Gateway**
    - Auto-remediation gateway integrated directly into the `Parse::from()` workflow to end encoding nightmares once and for all.
    - *Features:* Automatic runtime detection of legacy charsets (like `Windows-1254`, `ISO-8859-9`, `UTF-16`) and transparent background translation into pure, uncorrupted UTF-8 before parsing begins. Perfect for reliable localization.
- **Advanced Typography &amp; Rich Document Object Model (DOM)**
    - Moving away from flat string arrays to an isolated, robust typography and word-granularity ecosystem without breaking backward compatibility or complicating simple formats like SRT.
    - *Hierarchical Components:*
        - `Content\Style`: A standalone object housing explicit format properties like `bold`, `italic`, `underline`, and color codes (e.g., hex, named colors). Can be attached globally to an entire cue or down to individual tokens.
        - `Content\Document`: Replaces the raw text array inside `Entry::$content` but implements `ArrayAccess`, `Countable`, and `IteratorAggregate` to act exactly like an array from the outside.
        - `Content\Line`: Represents a single line within a subtitle block, managing an isolated collection of structural words/tokens.
        - `Content\Word`: The atomic unit of text. Houses individual token values, custom word-level styling, and temporal offsets relative to the main cue's timeline (essential for implementing state-of-the-art **Karaoke / Word-by-Word** highlighting modes like in advanced WebVTT/ASS workflows).

---

License
-------

[](#license)

This library is released under the [MIT License](https://github.com/ismailceylan/php-subtitle-toolkit/blob/main/LICENSE).

###  Health Score

31

—

LowBetter than 65% of packages

Maintenance91

Actively maintained with recent releases

Popularity2

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity23

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% 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

Unknown

Total

1

Last Release

45d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/2580129?v=4)[İsmail Ceylan](/maintainers/ismailceylan)[@ismailceylan](https://github.com/ismailceylan)

---

Top Contributors

[![ismailceylan](https://avatars.githubusercontent.com/u/2580129?v=4)](https://github.com/ismailceylan "ismailceylan (44 commits)")

### Embed Badge

![Health badge](/badges/iceylan-subtitle/health.svg)

```
[![Health](https://phpackages.com/badges/iceylan-subtitle/health.svg)](https://phpackages.com/packages/iceylan-subtitle)
```

###  Alternatives

[sauladam/shipment-tracker

Parses tracking information for several carriers, like UPS, USPS, DHL and GLS by simply scraping the data. No need for any kind of API access.

9843.5k](/packages/sauladam-shipment-tracker)[jstewmc/rtf

Read and write Rich Text Format (RTF) documents with PHP

45153.1k6](/packages/jstewmc-rtf)[tcds-io/php-jackson

A lightweight, flexible object serializer for PHP, inspired by FasterXML/jackson

113.2k10](/packages/tcds-io-php-jackson)

PHPackages © 2026

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